LCOV - code coverage report
Current view: top level - antares-api/src - lib.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.5 % 4516 4449
Test Date: 2026-09-21 10:31:06 Functions: 67.7 % 933 632

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! NGSI-LD HTTP binding: axum routers, thin
       3              : //! handlers per spec operation.
       4              : #![cfg_attr(not(test), warn(clippy::expect_used))]
       5              : #![cfg_attr(test, allow(clippy::unwrap_used))]
       6              : // the Send proof of the spawned snapshot fill walks temporal → entity map
       7              : // futures deeper than the default 128 (nightly: recursion_depth_exceeding_limit)
       8              : #![recursion_limit = "256"]
       9              : 
      10              : /// Ask the policy engine about one operation (ADR-0020), where the request
      11              : /// enters its handler:
      12              : ///
      13              : /// ```ignore
      14              : /// gate!(st, &tenant, headers, "5.6.6", ids: &[&id]).await?;
      15              : /// ```
      16              : ///
      17              : /// The named members go into the [`policy::Operation`]; every other member
      18              : /// keeps the empty default of `Operation::new`. The awaited value is the
      19              : /// `Filter` the engine narrowed the operation to, or the refusal that
      20              : /// becomes a 403. It is a macro because a gate reads as one line at the
      21              : /// call site and the alternative is the same eight lines in every handler.
      22              : ///
      23              : /// With no engine attached there is nothing to ask, so nothing is built:
      24              : /// the operation, the subject and its headers, the boxed future and the
      25              : /// [`policy::TIMEOUT`] timer all belong to the arm that has an engine to
      26              : /// ask. A broker running the default pays one branch per operation.
      27              : macro_rules! gate {
      28              :     ($st:expr, $tenant:expr, $headers:expr, $clause:expr $(, $field:ident: $value:expr)* $(,)?) => {
      29        23256 :         async {
      30        23256 :             match &$st.policy {
      31        22572 :                 None => Ok($crate::policy::Filter::default()),
      32          684 :                 Some(engine) => {
      33          684 :                     $crate::policy::gate(
      34          684 :                         engine.as_ref(),
      35          684 :                         &$crate::snapshots::asking_tenant(&$st, $tenant).await,
      36              :                         $headers,
      37          684 :                         &$crate::policy::Operation {
      38          684 :                             $($field: $value,)*
      39          684 :                             ..$crate::policy::Operation::new($clause)
      40          684 :                         },
      41              :                     )
      42          684 :                     .await
      43              :                 }
      44              :             }
      45        23256 :         }
      46              :     };
      47              : }
      48              : 
      49              : pub(crate) mod attrs;
      50              : pub(crate) mod batch;
      51              : pub mod bounds;
      52              : pub mod conformance;
      53              : pub(crate) mod contexts;
      54              : pub(crate) mod csource;
      55              : pub(crate) mod distsub;
      56              : pub mod egress;
      57              : pub(crate) mod entities;
      58              : pub(crate) mod entity_map;
      59              : pub(crate) mod entity_maps;
      60              : pub(crate) mod federation;
      61              : pub mod history;
      62              : pub mod mirror;
      63              : pub mod negotiate;
      64              : pub mod notify;
      65              : pub(crate) mod paging;
      66              : pub mod policy;
      67              : pub(crate) mod registry;
      68              : pub(crate) mod repr;
      69              : pub(crate) mod snapshots;
      70              : pub(crate) mod stamp;
      71              : pub mod state;
      72              : pub(crate) mod subscriptions;
      73              : pub(crate) mod temporal;
      74              : pub(crate) mod temporalq;
      75              : pub(crate) mod types_attrs;
      76              : 
      77              : pub use antares_notifier::DeliveryPolicy;
      78              : pub use state::{AppState, TemporalRecord};
      79              : 
      80              : /// Spawn for both targets — tokio natively, the JS microtask queue on
      81              : /// wasm32 (no tokio runtime exists in a browser). Call sites are identical;
      82              : /// only the executor differs. Send is required natively (worker threads) and
      83              : /// meaningless on single-threaded wasm.
      84              : #[cfg(not(target_arch = "wasm32"))]
      85         2712 : pub fn spawn<F>(fut: F)
      86         2712 : where
      87         2712 :     F: std::future::Future<Output = ()> + Send + 'static,
      88              : {
      89         2712 :     BACKGROUND.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
      90         2712 :     tokio::spawn(async move {
      91         2546 :         fut.await;
      92         2537 :         BACKGROUND.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
      93         2537 :     });
      94         2712 : }
      95              : 
      96              : /// Work a request left behind after its response — the remote leg of a
      97              : /// distributed subscription, an initial Context Source notification, a
      98              : /// forwarded notification, a retry. Counted so a shutdown drain waits for
      99              : /// it: a rolling update that killed these tasks left the chain half-built.
     100              : static BACKGROUND: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
     101              : 
     102              : /// Request-born tasks still running.
     103           40 : pub fn background_tasks() -> usize {
     104           40 :     BACKGROUND.load(std::sync::atomic::Ordering::SeqCst)
     105           40 : }
     106              : 
     107              : /// A task that lives as long as the process (the matcher drain, the
     108              : /// interval tick): never counted, or a drain would wait on it forever.
     109              : #[cfg(not(target_arch = "wasm32"))]
     110         1120 : pub(crate) fn spawn_loop<F>(fut: F)
     111         1120 : where
     112         1120 :     F: std::future::Future<Output = ()> + Send + 'static,
     113              : {
     114         1120 :     tokio::spawn(fut);
     115         1120 : }
     116              : 
     117              : #[cfg(target_arch = "wasm32")]
     118              : pub(crate) fn spawn_loop<F>(fut: F)
     119              : where
     120              :     F: std::future::Future<Output = ()> + 'static,
     121              : {
     122              :     wasm_bindgen_futures::spawn_local(fut);
     123              : }
     124              : 
     125              : #[cfg(target_arch = "wasm32")]
     126              : pub fn spawn<F>(fut: F)
     127              : where
     128              :     F: std::future::Future<Output = ()> + 'static,
     129              : {
     130              :     wasm_bindgen_futures::spawn_local(fut);
     131              : }
     132              : 
     133              : /// The browser build's notification channel: re-exported from the HTTP
     134              : /// binding, where it belongs — the page sink is how that binding delivers
     135              : /// when the runtime has no inbound socket.
     136              : #[cfg(target_arch = "wasm32")]
     137              : pub use antares_notifier::http::page_sink;
     138              : 
     139              : use antares_model::{NgsiError, TenantId, API_ROOT};
     140              : use axum::http::{HeaderMap, StatusCode};
     141              : use axum::response::{IntoResponse, Response};
     142              : use axum::routing::{delete, get, patch, post};
     143              : use axum::Router;
     144              : use negotiate::{echo_tenant, respond, tenant_from, ApiError};
     145              : 
     146              : /// 5.5.4 Fragment applied to a stored document resource — a Subscription
     147              : /// (5.8.2), a Context Source Registration (5.9.3) or a Context Source
     148              : /// Subscription (5.11.4). Each member of the already-normalized Fragment
     149              : /// replaces the target's; a member that normalization left as JSON null is
     150              : /// the removal form and takes the member out; `id` is never touched, and the
     151              : /// write stamps `modifiedAt` (4.8). Entities do NOT go through here: 5.5.12
     152              : /// Merge Patch descends into Attribute instances and value objects, which is
     153              : /// `entities::merge_instance`.
     154          116 : pub(crate) fn apply_doc_fragment(
     155          116 :     target: &mut serde_json::Map<String, serde_json::Value>,
     156          116 :     fragment: &serde_json::Map<String, serde_json::Value>,
     157          116 :     ts: &str,
     158          116 : ) {
     159          116 :     for (k, v) in fragment {
     160          106 :         if k == "id" {
     161            0 :             continue;
     162          106 :         }
     163          106 :         if v.is_null() {
     164            0 :             target.remove(k);
     165          106 :         } else {
     166          106 :             target.insert(k.clone(), v.clone());
     167          106 :         }
     168              :     }
     169          116 :     target.insert(
     170          116 :         "modifiedAt".into(),
     171          116 :         serde_json::Value::String(ts.to_owned()),
     172              :     );
     173          116 : }
     174              : 
     175              : pub(crate) mod surface;
     176              : pub use surface::ApiSurface;
     177              : 
     178              : pub use antares_ql::scope::scope_matches;
     179              : 
     180              : /// 4.3.5 NGSI-LD API structure: Core API mandatory; Distributed API mandatory for
     181              : /// distributed/federated deployments; Temporal API and Registry API integrated
     182              : /// locally here (Table 4.3.5-2 row "integrated temporal + integrated Context
     183              : /// Registry"); JSONLDContext API implemented; optional Snapshot API offered
     184              : /// (5.16, resources 6.36-6.38, NGSILD-Snapshot scoping 6.3.22).
     185              : /// Ops-only router: what a pod WITHOUT the api role serves —
     186              : /// health, readiness and metrics, nothing else. The shipped antares-worker
     187              : /// Deployment used to answer the full read/write NGSI-LD API on its pod IP
     188              : /// (and a subscription created there was never KV-synced, because the sync
     189              : /// hooks are wired `if roles.api`); a worker now 404s the API surface.
     190            4 : pub fn ops_router(state: AppState) -> Router {
     191              :     // Admin only, deliberately: a worker widening its surface to whatever a
     192              :     // deployment registered is the same class of accident this router exists
     193              :     // to prevent.
     194            4 :     Router::new()
     195            4 :         .nest(Admin.prefix(), Admin.router(state.clone()))
     196            4 :         .with_state(state)
     197            4 : }
     198              : 
     199              : /// The broker's own operational surface: health, readiness, metrics, the
     200              : /// tenant list and purge, and the dead-letter admin. Gateway-protected like
     201              : /// every `/q` route — the broker adds no authentication of its own.
     202              : pub struct Admin;
     203              : 
     204              : impl Admin {
     205              :     /// Every path this surface mounts, and the only source of the route
     206              :     /// count `/q/health` reports. The destructuring in `router` binds one
     207              :     /// name per entry, so a route added to one and not the other does not
     208              :     /// compile.
     209              :     const PATHS: [&'static str; 8] = [
     210              :         "/health",
     211              :         "/ready",
     212              :         "/metrics",
     213              :         "/tenants",
     214              :         "/tenants/{tenant}",
     215              :         "/dead-letters",
     216              :         "/dead-letters/{id}",
     217              :         "/dead-letters/{id}/replay",
     218              :     ];
     219              : }
     220              : 
     221              : impl ApiSurface for Admin {
     222           94 :     fn name(&self) -> &str {
     223           94 :         "admin"
     224           94 :     }
     225              : 
     226        13017 :     fn prefix(&self) -> &str {
     227        13017 :         "/q"
     228        13017 :     }
     229              : 
     230        12831 :     fn router(&self, _st: AppState) -> Router<AppState> {
     231        12831 :         let [health_p, ready_p, metrics_p, tenants_p, tenant_p, dl_p, dl_id_p, replay_p] =
     232        12831 :             Self::PATHS;
     233        12831 :         Router::new()
     234        12831 :             .route(health_p, get(health))
     235        12831 :             .route(ready_p, get(ready))
     236              :             // Prometheus text format. 404 until the broker installs the
     237              :             // renderer — the api crate never depends on an exporter.
     238        12831 :             .route(metrics_p, get(metrics_endpoint))
     239        12831 :             .route(tenants_p, get(tenants_list))
     240        12831 :             .route(tenant_p, get(tenant_get).delete(tenant_purge))
     241        12831 :             .route(dl_p, get(dead_letters_list))
     242        12831 :             .route(dl_id_p, delete(dead_letter_delete))
     243        12831 :             .route(replay_p, post(dead_letter_replay))
     244        12831 :     }
     245              : 
     246           90 :     fn version_info(&self) -> serde_json::Value {
     247           90 :         serde_json::json!({"routes": Self::PATHS.len()})
     248           90 :     }
     249              : }
     250              : 
     251              : /// Installs the in-process pipeline on a state: the subscription mirror and
     252              : /// matcher (5.8.6), the interval firing, and the consumer half of a
     253              : /// distributed subscription (5.8.1.4) — a notification arriving on the
     254              : /// internal endpoint is handed to `distsub` from here, so the delivery path
     255              : /// never names it. Every root that serves requests calls this once.
     256          560 : pub async fn wire(state: &mut AppState) {
     257          560 :     notify::wire_matcher(state).await;
     258          560 :     install_csource_notification(state);
     259          560 : }
     260              : 
     261              : /// The 5.8.1.4 consumer half on its own, for a root that wires its matcher
     262              : /// some other way. A distributed Subscription forwards no copy until this
     263              : /// handler exists: the internal Context Source Registration Subscription
     264              : /// notifies to `urn:antares:distsub:…`, and the delivery path drops that
     265              : /// notification when nothing is installed to take it.
     266          572 : pub fn install_csource_notification(state: &mut AppState) {
     267          572 :     state.csource_notification = Some(std::sync::Arc::new(|st, tenant, own_id, reason, regs| {
     268           59 :         Box::pin(distsub::on_csource_notification(
     269           59 :             st, tenant, own_id, reason, regs,
     270           59 :         ))
     271           59 :     }));
     272          572 : }
     273              : 
     274              : /// 4.22 for the documents that carry their own `expiresAt`: Registrations,
     275              : /// Snapshots and EntityMaps. The store's own sweep reaps Entities, which is
     276              : /// what 4.22 names; these three are refused by every read once their expiry
     277              : /// passes but nothing collected them, so they stayed for the life of the
     278              : /// broker. Each kind is reaped by the predicate its own reader uses, so the
     279              : /// sweep removes exactly what a read already hides — never more.
     280              : ///
     281              : /// Opportunistic by construction: a driver that cannot enumerate its
     282              : /// tenants reaps nothing and the reads keep hiding what is left, and a
     283              : /// tenant whose walk fails is skipped rather than failing the tick.
     284              : ///
     285              : /// The walk is the client tenants. A Snapshot's frozen copy carries its own
     286              : /// expiry on the Snapshot document, and expiring it drops the whole synthetic
     287              : /// tenant (`snap_remove`), so there is nothing left inside for a per-document
     288              : /// reaper to find.
     289          784 : pub async fn sweep_expired_docs(st: &AppState) -> usize {
     290          784 :     let Ok(tenants) = st.store.tenant_ids().await else {
     291            0 :         return 0;
     292              :     };
     293          784 :     let mut n = 0;
     294        20050 :     for name in tenants {
     295        20050 :         let Ok(tenant) = antares_model::TenantId::new(&name) else {
     296            0 :             continue;
     297              :         };
     298        20050 :         n += csource::sweep_expired_registrations(st, &tenant).await;
     299        20050 :         n += snapshots::sweep_expired_snapshots(st, &tenant).await;
     300        20050 :         n += entity_map::sweep_expired_maps(st, &tenant).await;
     301              :     }
     302          784 :     n
     303          784 : }
     304              : 
     305              : /// An `AppState` with the notification pipeline installed, for tests that
     306              : /// exercise a route which notifies. The router does not wire it: a caller
     307              : /// that only reads never pays for the mirror seed.
     308              : #[cfg(any(test, feature = "test-kit"))]
     309           52 : pub async fn wired_state(host_alias: &str) -> AppState {
     310           52 :     let mut st = AppState::new(host_alias.to_owned());
     311           52 :     wire(&mut st).await;
     312           52 :     st
     313           52 : }
     314              : 
     315        12835 : pub fn router(state: AppState) -> Router {
     316        12835 :     let api = Router::new()
     317              :         // entities (6.4/6.5)
     318        12835 :         .route(
     319        12835 :             "/entities",
     320        12835 :             post(entities::create_entity)
     321        12835 :                 .get(entities::query_entities)
     322        12835 :                 .delete(entities::purge_entities)
     323        12835 :                 .patch(missing_entity_id)
     324        12835 :                 .put(missing_entity_id),
     325              :         )
     326        12835 :         .route(
     327        12835 :             "/entities/{id}",
     328        12835 :             get(entities::retrieve_entity)
     329        12835 :                 .patch(entities::merge_entity)
     330        12835 :                 .put(entities::replace_entity)
     331        12835 :                 .delete(entities::delete_entity),
     332              :         )
     333              :         // attrs (6.6/6.7)
     334        12835 :         .route(
     335        12835 :             "/entities/{id}/attrs",
     336        12835 :             post(attrs::append_attrs).patch(attrs::update_attrs),
     337              :         )
     338        12835 :         .route(
     339        12835 :             "/entities/{id}/attrs/{attr}",
     340              :             // GET is the 2.0 #14 pre-adoption
     341        12835 :             get(entities::retrieve_entity_attr)
     342        12835 :                 .patch(attrs::partial_update_attr)
     343        12835 :                 .put(attrs::replace_attr)
     344        12835 :                 .delete(attrs::delete_attr),
     345              :         )
     346              :         // 2.0 #15 pre-adoption: the bare attribute value
     347        12835 :         .route(
     348        12835 :             "/entities/{id}/attrs/{attr}/value",
     349        12835 :             get(entities::retrieve_entity_attr_value),
     350              :         )
     351              :         // batch (6.14–6.17, 6.23, 6.31)
     352        12835 :         .route("/entityOperations/create", post(batch::batch_create))
     353        12835 :         .route("/entityOperations/upsert", post(batch::batch_upsert))
     354        12835 :         .route("/entityOperations/update", post(batch::batch_update))
     355        12835 :         .route("/entityOperations/delete", post(batch::batch_delete))
     356        12835 :         .route("/entityOperations/merge", post(batch::batch_merge))
     357        12835 :         .route("/entityOperations/query", post(batch::batch_query))
     358              :         // EntityMaps (5.14; 6.32/6.34/6.35)
     359        12835 :         .route(
     360        12835 :             "/entityMaps",
     361        12835 :             get(entity_maps::create_entity_map).post(entity_maps::create_entity_map_post),
     362              :         )
     363        12835 :         .route(
     364        12835 :             "/entityMaps/{id}",
     365        12835 :             get(entity_maps::retrieve_entity_map)
     366        12835 :                 .patch(entity_maps::update_entity_map)
     367        12835 :                 .delete(entity_maps::delete_entity_map),
     368              :         )
     369        12835 :         .route(
     370        12835 :             "/temporal/entityMaps",
     371        12835 :             get(entity_maps::create_temporal_entity_map)
     372        12835 :                 .post(entity_maps::create_temporal_entity_map_post),
     373              :         )
     374              :         // subscriptions (6.10/6.11)
     375        12835 :         .route(
     376        12835 :             "/subscriptions",
     377        12835 :             post(subscriptions::create_subscription).get(subscriptions::query_subscriptions),
     378              :         )
     379        12835 :         .route(
     380        12835 :             "/subscriptions/{id}",
     381        12835 :             get(subscriptions::retrieve_subscription)
     382        12835 :                 .patch(subscriptions::update_subscription)
     383        12835 :                 .delete(subscriptions::delete_subscription),
     384              :         )
     385              :         // csourceRegistrations (6.8/6.9)
     386        12835 :         .route(
     387        12835 :             "/csourceRegistrations",
     388        12835 :             post(csource::create_registration).get(csource::query_registrations),
     389              :         )
     390        12835 :         .route(
     391        12835 :             "/csourceRegistrations/{id}",
     392        12835 :             get(csource::retrieve_registration)
     393        12835 :                 .patch(csource::update_registration)
     394        12835 :                 .delete(csource::delete_registration),
     395              :         )
     396              :         // csourceSubscriptions (6.12/6.13)
     397        12835 :         .route(
     398        12835 :             "/csourceSubscriptions",
     399        12835 :             post(subscriptions::create_csource_subscription)
     400        12835 :                 .get(subscriptions::query_csource_subscriptions),
     401              :         )
     402        12835 :         .route(
     403        12835 :             "/csourceSubscriptions/{id}",
     404        12835 :             get(subscriptions::retrieve_csource_subscription)
     405        12835 :                 .patch(subscriptions::update_csource_subscription)
     406        12835 :                 .delete(subscriptions::delete_csource_subscription),
     407              :         )
     408              :         // temporal (6.18–6.22, 6.24)
     409        12835 :         .route(
     410        12835 :             "/temporal/entities",
     411        12835 :             post(temporal::upsert_temporal).get(temporal::query_temporal),
     412              :         )
     413        12835 :         .route(
     414        12835 :             "/temporal/entities/{id}",
     415        12835 :             get(temporal::retrieve_temporal).delete(temporal::delete_temporal),
     416              :         )
     417        12835 :         .route(
     418        12835 :             "/temporal/entities/{id}/attrs",
     419        12835 :             post(temporal::add_temporal_attrs),
     420              :         )
     421        12835 :         .route(
     422        12835 :             "/temporal/entities/{id}/attrs/{attr}",
     423        12835 :             delete(temporal::delete_temporal_attr),
     424              :         )
     425        12835 :         .route(
     426        12835 :             "/temporal/entities/{id}/attrs/{attr}/{instance}",
     427        12835 :             patch(temporal::modify_temporal_instance).delete(temporal::delete_temporal_instance),
     428              :         )
     429        12835 :         .route(
     430        12835 :             "/temporal/entityOperations/query",
     431        12835 :             post(temporal::batch_temporal_query),
     432              :         )
     433              :         // discovery (6.25–6.28)
     434        12835 :         .route("/types", get(types_attrs::entity_types))
     435        12835 :         .route("/types/{type}", get(types_attrs::entity_type_info))
     436        12835 :         .route("/attributes", get(types_attrs::attributes))
     437        12835 :         .route("/attributes/{attr}", get(types_attrs::attribute_info))
     438              :         // jsonldContexts (6.29/6.30)
     439        12835 :         .route(
     440        12835 :             "/jsonldContexts",
     441        12835 :             post(contexts::add_context).get(contexts::list_contexts),
     442              :         )
     443        12835 :         .route(
     444        12835 :             "/jsonldContexts/{id}",
     445        12835 :             get(contexts::serve_context).delete(contexts::delete_context),
     446              :         )
     447              :         // snapshots (5.16; 6.36-6.38, optional API group — offered)
     448        12835 :         .route(
     449        12835 :             "/snapshots",
     450        12835 :             post(snapshots::create_snapshot).delete(snapshots::purge_snapshots),
     451              :         )
     452        12835 :         .route(
     453        12835 :             "/snapshots/{id}",
     454        12835 :             get(snapshots::retrieve_snapshot)
     455        12835 :                 .patch(snapshots::update_snapshot)
     456        12835 :                 .delete(snapshots::delete_snapshot),
     457              :         )
     458        12835 :         .route("/snapshots/{id}/clone", post(snapshots::clone_snapshot))
     459              :         // info (6.33)
     460        12835 :         .route("/info/sourceIdentity", get(source_identity));
     461              : 
     462              :     // 5.8.1.4 consumer half: where forwarded subscription copies point their
     463              :     // notifications; remapped to the original subscriber. CIM 009 defines no
     464              :     // path for it — 5.2.15 makes a notification endpoint any URI, and 6.2
     465              :     // standardizes only what hangs under the API root — so it lives outside
     466              :     // the `/ngsi-ld` prefix ETSI owns, under this broker's own versioned
     467              :     // peer-facing root (ADR-0019). Being outside the API nest, it carries the
     468              :     // bounds wall and the body limit itself: a peer-facing write path must
     469              :     // not be the one route where the documented caps do not apply.
     470        12835 :     let remote_notify = Router::new()
     471        12835 :         .route("/ex/v1/remote-notify", post(distsub::remote_notify))
     472        12835 :         .layer(axum::extract::DefaultBodyLimit::max(
     473        12835 :             *bounds::MAX_BODY_BYTES,
     474              :         ))
     475        12835 :         .layer(axum::middleware::from_fn_with_state(
     476        12835 :             state.clone(),
     477              :             bounds::bounds_layer,
     478              :         ));
     479              : 
     480              :     // Every registered surface, each under its own reserved prefix — the
     481              :     // admin one by default. `with_surface` already refused a prefix outside
     482              :     // /q and /x and any overlap, so the merge here cannot shadow a route.
     483              :     // The caps are the broker's, not the API nest's: these routes carry the
     484              :     // bounds wall and the body limit for the same reason the peer-facing
     485              :     // notification endpoint above does — /q deletes Tenants and replays dead
     486              :     // letters, and a deployment's own /x routes are no less a way in.
     487        12835 :     let mut surfaces = Router::new();
     488        12917 :     for s in state.surfaces.iter() {
     489        12917 :         surfaces = surfaces.nest(s.prefix(), s.router(state.clone()));
     490        12917 :     }
     491        12835 :     let surfaces = surfaces
     492        12835 :         .layer(axum::extract::DefaultBodyLimit::max(
     493        12835 :             *bounds::MAX_BODY_BYTES,
     494              :         ))
     495        12835 :         .layer(axum::middleware::from_fn_with_state(
     496        12835 :             state.clone(),
     497              :             bounds::bounds_layer,
     498              :         ));
     499              : 
     500        12835 :     surfaces
     501        12835 :         .merge(remote_notify)
     502              :         // 6.3.6/6.3.21: Prefer: ngsi-ld=<version> → 4.3.6.8 amendment +
     503              :         // Preference-Applied (+203 when altered) on every API response.
     504              :         // OPTIONS (2.0 #59 pre-adoption): axum's MethodRouter already
     505              :         // computes the exact per-route Allow set for its 405s — the layer
     506              :         // turns an OPTIONS 405 into 204 + that same Allow. HEAD (#58) needs
     507              :         // nothing: axum's get() serves HEAD natively.
     508        12835 :         .nest(
     509        12835 :             API_ROOT,
     510        12835 :             api.layer(axum::middleware::from_fn(conformance::prefer_version_layer))
     511              :                 // The temporal seam's drain: every write's history events
     512              :                 // land in one driver call once the handler is done.
     513        12835 :                 .layer(axum::middleware::from_fn_with_state(
     514        12835 :                     state.clone(),
     515              :                     history::layer,
     516              :                 ))
     517              :                 // 6.3.22 / 5.5.15: NGSILD-Snapshot resolves to the
     518              :                 // snapshot's synthetic tenant, so the handlers below serve
     519              :                 // the frozen copy.
     520        12835 :                 .layer(axum::middleware::from_fn_with_state(
     521        12835 :                     state.clone(),
     522              :                     snapshots::snapshot_layer,
     523              :                 ))
     524              :                 // 5.5.10: non-create operations targeting a non-existing
     525              :                 // Tenant answer NonexistentTenant 404; create operations
     526              :                 // implicitly create the Tenant. Outside the snapshot layer,
     527              :                 // so a client naming one of the broker's own internal
     528              :                 // tenants is refused before that layer legitimately sets
     529              :                 // one.
     530        12835 :                 .layer(axum::middleware::from_fn_with_state(
     531        12835 :                     state.clone(),
     532              :                     tenant_exists_layer,
     533              :                 ))
     534              :                 // Bounds wall: URI length, body size, JSON depth — checked
     535              :                 // before any parse (size-check-before-parse), and outside the
     536              :                 // tenant and snapshot lookups so 6.3.4's bare 411/414 is not
     537              :                 // spent on a store round-trip or masked by its 404.
     538        12835 :                 .layer(axum::middleware::from_fn_with_state(
     539        12835 :                     state.clone(),
     540              :                     bounds::bounds_layer,
     541              :                 ))
     542              :                 // axum's built-in extractor limit defaults to 2 MiB and fires
     543              :                 // BEFORE the bounds wall's documented cap — a 3 MiB body was
     544              :                 // 413'd although /q/health advertises maxBodyBytes = 4 MiB
     545              :                 // (found by mutation-testing the 413 wall). One
     546              :                 // number governs both walls.
     547        12835 :                 .layer(axum::extract::DefaultBodyLimit::max(
     548        12835 :                     *bounds::MAX_BODY_BYTES,
     549              :                 ))
     550              :                 // 6.3.14, last so it sees every exit: outside the snapshot
     551              :                 // layer, which rewrites the request header to the snapshot's
     552              :                 // internal tenant, and outside the bounds wall, whose bare
     553              :                 // 411/414 leave through no handler at all.
     554        12835 :                 .layer(axum::middleware::from_fn(echo_tenant_layer)),
     555              :         )
     556        12835 :         .fallback(not_found)
     557              :         // outermost on purpose: axum attaches the 405 Allow header in the
     558              :         // Router itself, above any nested layer — only a layer wrapping the
     559              :         // WHOLE router sees it.
     560        12835 :         .layer(axum::middleware::from_fn(options_204))
     561              :         // Outermost so the duration covers the full stack.
     562        12835 :         .layer(axum::middleware::from_fn(http_metrics_layer))
     563              :         // absent knob = no layer at all: a bare OPTIONS keeps its 204 + Allow
     564        12835 :         .layer(tower::util::option_layer(cors_layer()))
     565        12835 :         .with_state(state)
     566        12835 : }
     567              : 
     568              : /// Browser access is off unless ANTARES_CORS_ORIGINS names the origins
     569              : /// (comma-separated, or `*` for any). NGSI-LD says nothing about CORS; the
     570              : /// Link header and the NGSILD-Tenant / NGSILD-Results-Count headers are
     571              : /// exposed so a browser client can read them.
     572        12835 : fn cors_layer() -> Option<tower_http::cors::CorsLayer> {
     573              :     use tower_http::cors::{AllowOrigin, CorsLayer};
     574        12835 :     let spec = std::env::var("ANTARES_CORS_ORIGINS")
     575        12835 :         .ok()
     576        12835 :         .filter(|s| !s.trim().is_empty())?;
     577            0 :     let origin = if spec.trim() == "*" {
     578            0 :         AllowOrigin::any()
     579              :     } else {
     580            0 :         AllowOrigin::list(
     581            0 :             spec.split(',')
     582            0 :                 .filter_map(|o| o.trim().parse::<axum::http::HeaderValue>().ok()),
     583              :         )
     584              :     };
     585            0 :     let layer = CorsLayer::new()
     586            0 :         .allow_origin(origin)
     587            0 :         .allow_methods(tower_http::cors::Any)
     588            0 :         .allow_headers(tower_http::cors::Any)
     589            0 :         .expose_headers([
     590            0 :             axum::http::header::LINK,
     591            0 :             axum::http::HeaderName::from_static("ngsild-tenant"),
     592            0 :             axum::http::HeaderName::from_static("ngsild-results-count"),
     593            0 :         ]);
     594            0 :     Some(layer)
     595        12835 : }
     596              : 
     597              : /// Tenants the broker mints for its own bookkeeping: the snapshot module's
     598              : /// internal and per-snapshot tenants share a prefix, the distributed
     599              : /// subscription inbound index is a single fixed name. A client-supplied
     600              : /// tenant may not name any of them — it would put request-shaped writes in
     601              : /// the same keyspace the broker keeps its own state in. The names live on
     602              : /// `TenantId`, where the constructor a client's name goes through refuses
     603              : /// them; the admin surfaces below ask by name, before any parse.
     604         2046 : fn reserved_tenant(raw: &str) -> bool {
     605         2046 :     TenantId::is_reserved_str(raw)
     606         2046 : }
     607              : 
     608              : /// 5.5.10 Multi-Tenant Behaviour: Tenants are created implicitly by create
     609              : /// operations (Create Entity 5.6.1, Batch Create/Upsert 5.6.7/5.6.8, Create
     610              : /// Temporal 5.6.11, Create Subscription 5.8.1, Register Context Source
     611              : /// 5.9.2, Create CSource Subscription 5.11.2); "all other NGSI-LD
     612              : /// operations … that target a non-existing Tenant should raise an error of
     613              : /// type NonexistentTenant". Malformed tenant headers pass through — the
     614              : /// handler's own parse answers 400.
     615        26184 : async fn tenant_exists_layer(
     616        26184 :     axum::extract::State(st): axum::extract::State<AppState>,
     617        26184 :     req: axum::http::Request<axum::body::Body>,
     618        26184 :     next: axum::middleware::Next,
     619        26184 : ) -> Response {
     620              :     // 6.3.14: the tenant namespace the broker mints for itself is not a
     621              :     // legal client tenant. Snapshots run on internal tenants named
     622              :     // "snap-index" (the synthetic-tenant reverse index) and "snap-<uuid>"
     623              :     // (one per snapshot); a client naming one would read and delete another
     624              :     // tenant's snapshot bookkeeping. Rejected here, ahead of the snapshot
     625              :     // layer that legitimately rewrites the header to such a tenant.
     626              :     // Read once, and refuse a request that names two Tenants (or one in a
     627              :     // form no header can carry): the guard below and the handler further in
     628              :     // have to be deciding about the same value, or the wall inspects one
     629              :     // name while the operation runs on another.
     630        26184 :     let named = match crate::negotiate::single_header(req.headers(), "NGSILD-Tenant") {
     631        26180 :         Ok(named) => named,
     632            4 :         Err(e) => return e.into_response(),
     633              :     };
     634        26180 :     if let Some(raw) = &named {
     635         1894 :         if reserved_tenant(raw) {
     636           44 :             return crate::negotiate::ApiError::from(NgsiError::BadRequestData(format!(
     637           44 :                 "invalid NGSILD-Tenant value: {raw:?}"
     638           44 :             )))
     639           44 :             .into_response();
     640         1850 :         }
     641        24286 :     }
     642        26136 :     let path = req.uri().path().trim_start_matches(API_ROOT);
     643        26136 :     let implicit_create = req.method() == axum::http::Method::POST
     644        14226 :         && matches!(
     645        15602 :             path,
     646        15602 :             "/entities"
     647         5632 :                 | "/entityOperations/create"
     648         5470 :                 | "/entityOperations/upsert"
     649         5376 :                 | "/temporal/entities"
     650         4560 :                 | "/subscriptions"
     651         3794 :                 | "/csourceRegistrations"
     652         1550 :                 | "/csourceSubscriptions"
     653              :         );
     654              :     // tenant-independent resources: broker identity (6.33) answers for any
     655              :     // tenant (the per-tenant alias exists before any data does), and the
     656              :     // @context resources are keyed by local id across tenants (5.13) — a
     657              :     // Cached row belongs to no tenant, so the existence of the requesting
     658              :     // one decides nothing here. Ownership of a Hosted row is still enforced,
     659              :     // by `contexts::row_visible` on every serve, list and delete.
     660        26136 :     let tenant_free = path.starts_with("/info/") || path.starts_with("/jsonldContexts");
     661        26136 :     if !implicit_create && !tenant_free {
     662        10966 :         if let Some(t) = named
     663        10966 :             .as_deref()
     664        10966 :             .and_then(|s| antares_model::TenantId::new(s).ok())
     665              :         {
     666          292 :             match st.store.tenant_exists(&t).await {
     667              :                 Ok(false) => {
     668           58 :                     let mut resp = crate::negotiate::ApiError::from(NgsiError::NonexistentTenant(
     669           58 :                         format!("tenant {} does not exist", t.as_str()),
     670           58 :                     ))
     671           58 :                     .into_response();
     672              :                     // 6.3.14: a request-supplied NGSILD-Tenant SHALL be
     673              :                     // present in the response — error responses included.
     674           58 :                     crate::negotiate::echo_tenant(&t, &mut resp);
     675           58 :                     return resp;
     676              :                 }
     677            0 :                 Err(e) => return crate::negotiate::ApiError::from(e).into_response(),
     678          234 :                 Ok(true) => {}
     679              :             }
     680        10674 :         }
     681        15170 :     }
     682        26078 :     next.run(req).await
     683        26184 : }
     684              : 
     685              : /// 6.3.14: "If the HTTP header `NGSILD-Tenant` is present in the HTTP
     686              : /// request, it shall also be present in HTTP response." The sentence admits
     687              : /// no exception, and the responses that most need it are the ones no handler
     688              : /// shapes: a rejection from the bounds wall, a 405, a body that never parsed.
     689              : /// An echo the handlers own is an echo some handler forgets, so it is read
     690              : /// once here, from the request the CALLER sent — the snapshot layer below
     691              : /// rewrites that header to the snapshot's internal tenant, which must never
     692              : /// reach the caller.
     693        26236 : async fn echo_tenant_layer(
     694        26236 :     req: axum::http::Request<axum::body::Body>,
     695        26236 :     next: axum::middleware::Next,
     696        26236 : ) -> Response {
     697              :     // A repeated header names no Tenant, so there is none to echo; the
     698              :     // BadRequestData the wall answers says so on its own.
     699        26236 :     let tenant = crate::negotiate::single_header(req.headers(), "NGSILD-Tenant")
     700        26236 :         .ok()
     701        26236 :         .flatten()
     702        26236 :         .and_then(|s| TenantId::new(&s).ok());
     703        26236 :     let mut resp = next.run(req).await;
     704        26236 :     if let Some(t) = tenant {
     705         1864 :         if !resp.headers().contains_key("NGSILD-Tenant") {
     706          106 :             crate::negotiate::echo_tenant(&t, &mut resp);
     707         1758 :         }
     708        24372 :     }
     709        26236 :     resp
     710        26236 : }
     711              : 
     712              : /// /q/metrics — Prometheus exposition, rendered by the closure the
     713              : /// broker installed; 404 when no recorder exists (tests, embedded builds).
     714            6 : async fn metrics_endpoint(axum::extract::State(state): axum::extract::State<AppState>) -> Response {
     715            6 :     match &state.metrics_render {
     716            0 :         Some(render) => Response::builder()
     717            0 :             .status(axum::http::StatusCode::OK)
     718            0 :             .header(
     719            0 :                 axum::http::header::CONTENT_TYPE,
     720              :                 "text/plain; version=0.0.4",
     721              :             )
     722            0 :             .body(axum::body::Body::from(render()))
     723            0 :             .unwrap_or_else(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()),
     724            6 :         None => axum::http::StatusCode::NOT_FOUND.into_response(),
     725              :     }
     726            6 : }
     727              : 
     728              : /// Bounded method label for the request metrics: standard HTTP methods
     729              : /// pass through, anything else (extension methods are minted by the
     730              : /// client) collapses to "OTHER" so the label's cardinality stays bounded.
     731        26810 : fn metric_method(m: &str) -> &'static str {
     732        26810 :     match m {
     733        26810 :         "GET" => "GET",
     734        22718 :         "POST" => "POST",
     735         6974 :         "PATCH" => "PATCH",
     736         6490 :         "PUT" => "PUT",
     737         6302 :         "DELETE" => "DELETE",
     738           16 :         "OPTIONS" => "OPTIONS",
     739           12 :         "HEAD" => "HEAD",
     740            8 :         _ => "OTHER",
     741              :     }
     742        26810 : }
     743              : 
     744              : /// Request counter + duration histogram, labelled by method and status
     745              : /// class only (bounded cardinality).
     746        26794 : async fn http_metrics_layer(
     747        26794 :     req: axum::http::Request<axum::body::Body>,
     748        26794 :     next: axum::middleware::Next,
     749        26794 : ) -> Response {
     750        26794 :     let method = metric_method(req.method().as_str());
     751              :     // Clock rule: std Instant panics on wasm32.
     752              :     #[cfg(not(target_arch = "wasm32"))]
     753        26794 :     let start = std::time::Instant::now();
     754              :     #[cfg(target_arch = "wasm32")]
     755              :     let start = web_time::Instant::now();
     756        26794 :     let resp = next.run(req).await;
     757        26794 :     let class = match resp.status().as_u16() {
     758        26794 :         100..=199 => "1xx",
     759        26794 :         200..=299 => "2xx",
     760        11730 :         300..=399 => "3xx",
     761        11730 :         400..=499 => "4xx",
     762         3938 :         _ => "5xx",
     763              :     };
     764        26794 :     metrics::counter!("antares_http_requests_total", "method" => method, "status" => class)
     765        26794 :         .increment(1);
     766        26794 :     metrics::histogram!("antares_http_request_duration_seconds", "method" => method)
     767        26794 :         .record(start.elapsed().as_secs_f64());
     768        26794 :     resp
     769        26794 : }
     770              : 
     771              : /// 2.0 #59 pre-adoption: OPTIONS → 204 No Content + the Allow set the
     772              : /// method router computed. Non-OPTIONS traffic passes through untouched.
     773        26794 : async fn options_204(
     774        26794 :     req: axum::http::Request<axum::body::Body>,
     775        26794 :     next: axum::middleware::Next,
     776        26794 : ) -> Response {
     777        26794 :     let is_options = req.method() == axum::http::Method::OPTIONS;
     778        26794 :     let resp = next.run(req).await;
     779        26794 :     if is_options && resp.status() == axum::http::StatusCode::METHOD_NOT_ALLOWED {
     780              :         // Keep the response PARTS: axum carries the computed Allow set as a
     781              :         // response extension and turns it into the header above all layers —
     782              :         // preserving the extensions preserves the Allow header.
     783            4 :         let (mut parts, _) = resp.into_parts();
     784            4 :         parts.status = axum::http::StatusCode::NO_CONTENT;
     785            4 :         return Response::from_parts(parts, axum::body::Body::empty());
     786        26790 :     }
     787        26790 :     resp
     788        26794 : }
     789              : 
     790              : /// The build-time git hash (build.rs) — re-exported for --version.
     791              : pub const GIT_HASH: &str = env!("ANTARES_GIT_HASH");
     792              : 
     793              : /// Liveness plus the deployment's own description: which drivers are
     794              : /// running, what they are, every configured cap with its rejection counters,
     795              : /// the mounted surfaces and the notification schemes they can deliver to.
     796              : /// 503 while draining, so a load balancer stops routing here before the
     797              : /// listener stops accepting.
     798           90 : async fn health(
     799           90 :     axum::extract::State(state): axum::extract::State<AppState>,
     800           90 : ) -> (StatusCode, axum::Json<serde_json::Value>) {
     801              :     // Drain: the load balancer decides on the STATUS CODE, so draining has
     802              :     // to be a 503 — a 200 body saying "DRAINING" would keep traffic arriving.
     803              :     // This flips BEFORE the listener stops accepting, which is the whole point
     804              :     // of the ordering: the LB must stop routing while the socket still works.
     805           90 :     let draining = state.draining.load(std::sync::atomic::Ordering::Relaxed);
     806           90 :     let code = if draining {
     807            2 :         StatusCode::SERVICE_UNAVAILABLE
     808              :     } else {
     809           88 :         StatusCode::OK
     810              :     };
     811           90 :     let mut body = serde_json::json!({
     812           90 :         "status": if draining { "DRAINING" } else { "UP" },
     813           90 :         "store": state.store_name,
     814           90 :         "temporal": state.temporal_name.as_deref().unwrap_or("none"),
     815              :         // Version surface: workspace version + build-time git hash
     816              :         // (build.rs), both asserted against the checkout by health_is_up.
     817           90 :         "version": env!("CARGO_PKG_VERSION"),
     818           90 :         "commit": env!("ANTARES_GIT_HASH"),
     819              :     });
     820              :     // What each driver runs on — engine, server version, extensions — so an
     821              :     // operator can tell two deployments of the same backend name apart. Both
     822              :     // seams answer from state captured at startup; a driver with nothing to
     823              :     // add answers an empty object and the key stays off the body.
     824          180 :     for (key, info) in [
     825           90 :         ("storeInfo", state.store.version_info()),
     826           90 :         ("temporalInfo", state.temporal.version_info()),
     827           90 :     ] {
     828          180 :         if info.as_object().is_some_and(|m| !m.is_empty()) {
     829          170 :             body[key] = info;
     830          170 :         }
     831              :     }
     832              :     // Where commits serialize behind one writer, the queue depth (current,
     833              :     // peak) is the signal that decides the group-commit lever. A driver
     834              :     // without such a committer reports nothing, so the branch is the
     835              :     // driver's, not a backend name read here.
     836           90 :     if let Some((depth, peak)) = state.store.commit_queue() {
     837            2 :         body["commitQueueDepth"] = depth.into();
     838            2 :         body["commitQueuePeak"] = peak.into();
     839           88 :     }
     840              :     // The policy engine this binary was started with, and how long it has
     841              :     // to answer before the seam denies for it (ADR-0020). An operator
     842              :     // reading `allow-all` here knows no engine is attached.
     843           90 :     body["policy"] = serde_json::json!({
     844           90 :         "engine": state.policy_name(),
     845           90 :         "timeoutMs": u64::try_from(policy::TIMEOUT.as_millis()).unwrap_or(u64::MAX),
     846              :     });
     847              :     // Configured caps + rejection counters, for observability.
     848           90 :     body["limits"] = state.limits.snapshot();
     849              :     // History events a driver failed to record after the write stood.
     850           90 :     body["temporalDrainErrors"] = history::drain_errors().into();
     851              :     // What this binary serves beside the NGSI-LD API: one entry per mounted
     852              :     // surface, its prefix and whatever the surface reports about itself.
     853           90 :     body["surfaces"] = serde_json::Value::Object(
     854           90 :         state
     855           90 :             .surfaces
     856           90 :             .iter()
     857          100 :             .map(|s| {
     858          100 :                 let mut info = match s.version_info() {
     859          100 :                     serde_json::Value::Object(m) => m,
     860            0 :                     other => {
     861            0 :                         let mut m = serde_json::Map::new();
     862            0 :                         m.insert("info".into(), other);
     863            0 :                         m
     864              :                     }
     865              :                 };
     866          100 :                 info.insert("prefix".into(), s.prefix().into());
     867          100 :                 (s.name().to_owned(), serde_json::Value::Object(info))
     868          100 :             })
     869           90 :             .collect(),
     870              :     );
     871              :     // Endpoint schemes this deployment can deliver notifications to: the
     872              :     // registered bindings (6.3.8, clause 7, and any a deployment added).
     873              :     // A subscription naming a scheme absent here is refused at creation.
     874           90 :     body["notificationSchemes"] = state.sinks.schemes().into();
     875              :     // Notifications the delivery policy gave up on (this process, since start).
     876           90 :     body["deadLetters"] = notify::dead_letters_written().into();
     877              :     // Changes the bounded matcher queue dropped (this process, since start).
     878           90 :     body["changesDropped"] = notify::changes_dropped().into();
     879              :     // Panics absorbed at the notification-task boundary, each one a change
     880              :     // whose notification was lost. Reported here and not only through the
     881              :     // metrics facade: the recorder is installed by ANTARES_TELEMETRY, which
     882              :     // is off by default, so this endpoint is where a default deployment can
     883              :     // see it at all.
     884           90 :     body["taskPanics"] = notify::task_panics().into();
     885              :     // Jemalloc heap stats (RSS ≈ live×1.2 is the target).
     886           90 :     if let Some(mem) = &state.mem_stats {
     887           16 :         body["memory"] = mem();
     888           74 :     }
     889              :     // Bus visibility: present only when the nats wiring installed the
     890              :     // provider — bus=local carries NO bus member.
     891           90 :     if let Some(bus) = &state.bus_stats {
     892            4 :         body["bus"] = bus();
     893           86 :     }
     894           90 :     (code, axum::Json(body))
     895           90 : }
     896              : 
     897              : /// The tenant an admin dead-letter call addresses: `?tenant=` (default
     898              : /// tenant when absent), grammar-checked, never a reserved internal one.
     899           76 : fn admin_tenant(q: &std::collections::HashMap<String, String>) -> Result<TenantId, NgsiError> {
     900           76 :     let raw = q.get("tenant").map_or(TenantId::DEFAULT, String::as_str);
     901           76 :     let bad = || NgsiError::BadRequestData(format!("invalid tenant: {raw:?}"));
     902           76 :     if reserved_tenant(raw) {
     903            8 :         return Err(bad());
     904           68 :     }
     905           68 :     TenantId::new(raw).map_err(|_| bad())
     906           76 : }
     907              : 
     908              : /// One dead letter as the listing may show it: everything on it that can be
     909              : /// a credential is blanked, the keys are not.
     910              : ///
     911              : /// `receiverInfo` and `notifierInfo` are KeyValuePair arrays (5.2.22), and
     912              : /// the example content the datatype names is the HTTP Authentication header;
     913              : /// a letter written before the bindings moved behind the registry carries the
     914              : /// same values already rendered into `headers`. `notifierInfo` is opaque to
     915              : /// every sink but the endpoint's own, so the broker cannot tell which of its
     916              : /// keys names a secret and blanks them all. The stored letter keeps the
     917              : /// values a replay has to send — only what leaves through this route loses
     918              : /// them.
     919           28 : fn redact_letter(l: &mut serde_json::Value) {
     920              :     // Assigning through `Value`'s index panics on anything but an object,
     921              :     // and the row comes from the store rather than from this process.
     922           28 :     let Some(letter) = l.as_object_mut() else {
     923            8 :         return;
     924              :     };
     925           20 :     if let Some(u) = letter.get("uri").and_then(serde_json::Value::as_str) {
     926           20 :         let redacted = serde_json::Value::String(notify::redact_userinfo(u));
     927           20 :         letter.insert("uri".into(), redacted);
     928           20 :     }
     929           60 :     for member in ["receiverInfo", "notifierInfo", "headers"] {
     930           60 :         let Some(pairs) = letter
     931           60 :             .get_mut(member)
     932           60 :             .and_then(serde_json::Value::as_array_mut)
     933              :         else {
     934           36 :             continue;
     935              :         };
     936           42 :         for pair in pairs {
     937           42 :             if let Some(v) = pair.get_mut(1) {
     938           42 :                 *v = serde_json::Value::String("[redacted]".into());
     939           42 :             }
     940              :         }
     941              :     }
     942           28 : }
     943              : 
     944              : /// Dead letters of one tenant, newest first; `subscription=` narrows to one
     945              : /// subscription, `limit=` bounds the page (default 100). The endpoint URI is
     946              : /// shown with its userinfo redacted and every credential the letter carries
     947              : /// is blanked.
     948           40 : async fn dead_letters_list(
     949           40 :     axum::extract::State(st): axum::extract::State<AppState>,
     950           40 :     axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
     951           40 : ) -> Response {
     952           40 :     let tenant = match admin_tenant(&q) {
     953           32 :         Ok(t) => t,
     954            8 :         Err(e) => return ApiError::from(e).into_response(),
     955              :     };
     956           32 :     let limit = match q.get("limit") {
     957           24 :         None => 100usize,
     958            8 :         Some(v) => match v.parse::<usize>() {
     959            4 :             Ok(n) if n > 0 => n,
     960              :             _ => {
     961            6 :                 return ApiError::from(NgsiError::BadRequestData(format!(
     962            6 :                     "limit must be a positive integer, got {v:?}"
     963            6 :                 )))
     964            6 :                 .into_response()
     965              :             }
     966              :         },
     967              :     };
     968           26 :     let mut letters = match st
     969           26 :         .store
     970           26 :         .list(&tenant, antares_store::Kind::DeadLetter)
     971           26 :         .await
     972              :     {
     973           26 :         Ok(l) => l,
     974            0 :         Err(e) => return ApiError::from(e).into_response(),
     975              :     };
     976           26 :     if let Some(sid) = q.get("subscription") {
     977            6 :         letters.retain(|l| l["subscriptionId"].as_str() == Some(sid.as_str()));
     978           24 :     }
     979           28 :     letters.sort_by(|a, b| b["lastAt"].as_str().cmp(&a["lastAt"].as_str()));
     980           26 :     letters.truncate(limit);
     981           30 :     for l in &mut letters {
     982           28 :         redact_letter(l);
     983           28 :     }
     984           26 :     (
     985           26 :         StatusCode::OK,
     986           26 :         axum::Json(serde_json::Value::Array(letters)),
     987           26 :     )
     988           26 :         .into_response()
     989           40 : }
     990              : 
     991              : /// Re-deliver one dead letter once through its own binding: 204 and the
     992              : /// letter is gone on success; 502 with the failure text and the letter
     993              : /// kept (attempt history extended) otherwise.
     994           24 : async fn dead_letter_replay(
     995           24 :     axum::extract::State(st): axum::extract::State<AppState>,
     996           24 :     axum::extract::Path(id): axum::extract::Path<String>,
     997           24 :     axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
     998           24 : ) -> Response {
     999              :     use antares_store::{CurrentStateDriverExt as _, Kind};
    1000           24 :     let tenant = match admin_tenant(&q) {
    1001           22 :         Ok(t) => t,
    1002            2 :         Err(e) => return ApiError::from(e).into_response(),
    1003              :     };
    1004           22 :     let letter = match st.store.get(&tenant, Kind::DeadLetter, &id).await {
    1005           12 :         Ok(Some(l)) => l,
    1006              :         Ok(None) => {
    1007           10 :             return ApiError::from(NgsiError::ResourceNotFound(format!("dead letter {id}")))
    1008           10 :                 .into_response()
    1009              :         }
    1010            0 :         Err(e) => return ApiError::from(e).into_response(),
    1011              :     };
    1012           12 :     match notify::replay_dead_letter(&st, &letter).await {
    1013            2 :         Ok(()) => match st.store.delete(&tenant, Kind::DeadLetter, &id).await {
    1014            2 :             Ok(_) => StatusCode::NO_CONTENT.into_response(),
    1015            0 :             Err(e) => ApiError::from(e).into_response(),
    1016              :         },
    1017           10 :         Err(why) => {
    1018           10 :             let ts = state::now_iso();
    1019           10 :             let w = why.clone();
    1020           10 :             let _ = st
    1021           10 :                 .store
    1022           10 :                 .mutate(&tenant, Kind::DeadLetter, &id, move |d| {
    1023           10 :                     let Some(letter) = d.as_object_mut() else {
    1024            2 :                         return Ok::<(), NgsiError>(());
    1025              :                     };
    1026            8 :                     let n = letter
    1027            8 :                         .get("attempts")
    1028            8 :                         .and_then(serde_json::Value::as_u64)
    1029            8 :                         .unwrap_or(0)
    1030            8 :                         + 1;
    1031            8 :                     letter.insert("attempts".into(), n.into());
    1032            8 :                     letter.insert("lastError".into(), serde_json::Value::String(w));
    1033            8 :                     letter.insert("lastAt".into(), serde_json::Value::String(ts));
    1034            8 :                     Ok::<(), NgsiError>(())
    1035           10 :                 })
    1036           10 :                 .await;
    1037           10 :             (
    1038           10 :                 StatusCode::BAD_GATEWAY,
    1039           10 :                 axum::Json(serde_json::json!({"detail": why, "id": id})),
    1040           10 :             )
    1041           10 :                 .into_response()
    1042              :         }
    1043              :     }
    1044           24 : }
    1045              : 
    1046              : /// Drop one dead letter without replaying it: 204 when it was there, 404
    1047              : /// when it was not. The letter is the only copy of a notification the
    1048              : /// delivery policy gave up on, so this is the one route that discards it.
    1049           12 : async fn dead_letter_delete(
    1050           12 :     axum::extract::State(st): axum::extract::State<AppState>,
    1051           12 :     axum::extract::Path(id): axum::extract::Path<String>,
    1052           12 :     axum::extract::Query(q): axum::extract::Query<std::collections::HashMap<String, String>>,
    1053           12 : ) -> Response {
    1054           12 :     let tenant = match admin_tenant(&q) {
    1055           10 :         Ok(t) => t,
    1056            2 :         Err(e) => return ApiError::from(e).into_response(),
    1057              :     };
    1058           10 :     match st
    1059           10 :         .store
    1060           10 :         .delete(&tenant, antares_store::Kind::DeadLetter, &id)
    1061           10 :         .await
    1062              :     {
    1063            2 :         Ok(true) => StatusCode::NO_CONTENT.into_response(),
    1064              :         Ok(false) => {
    1065            8 :             ApiError::from(NgsiError::ResourceNotFound(format!("dead letter {id}"))).into_response()
    1066              :         }
    1067            0 :         Err(e) => ApiError::from(e).into_response(),
    1068              :     }
    1069           12 : }
    1070              : 
    1071              : /// Tenant inventory: the names the backends know, sorted. Names only —
    1072              : /// at the 10 000-tenant target (ADR-0001) a list carrying per-kind counts
    1073              : /// would cost a count per kind per tenant, so a client picks a name here and
    1074              : /// reads its detail from `GET /q/tenants/{tenant}`. Admin surface, never
    1075              : /// under the API root.
    1076           20 : async fn tenants_list(axum::extract::State(st): axum::extract::State<AppState>) -> Response {
    1077           20 :     match st.store.tenant_ids().await {
    1078           20 :         Ok(ids) => (StatusCode::OK, axum::Json(ids)).into_response(),
    1079            0 :         Err(e) => crate::negotiate::ApiError::from(e).into_response(),
    1080              :     }
    1081           20 : }
    1082              : 
    1083              : /// What one tenant holds: 400 for a name no request could carry, 404 when it
    1084              : /// does not exist (5.5.10 keeps the default Tenant existing even when
    1085              : /// empty), 200 with its counts otherwise. The path names the tenant; the
    1086              : /// NGSILD-Tenant header never redirects the read.
    1087           32 : async fn tenant_get(
    1088           32 :     axum::extract::State(st): axum::extract::State<AppState>,
    1089           32 :     axum::extract::Path(raw): axum::extract::Path<String>,
    1090           32 : ) -> Response {
    1091              :     use crate::negotiate::ApiError;
    1092           32 :     let bad = || {
    1093           10 :         ApiError::from(NgsiError::BadRequestData(format!(
    1094           10 :             "invalid tenant: {raw:?}"
    1095           10 :         )))
    1096           10 :         .into_response()
    1097           10 :     };
    1098              :     // the broker's internal snapshot tenants are not addressable
    1099           32 :     if reserved_tenant(&raw) {
    1100            8 :         return bad();
    1101           24 :     }
    1102           24 :     let Ok(tenant) = TenantId::new(&raw) else {
    1103            2 :         return bad();
    1104              :     };
    1105           22 :     let stats = match st.store.tenant_stats_one(&tenant).await {
    1106           20 :         Ok(Some(s)) => s,
    1107              :         Ok(None) => {
    1108            2 :             return ApiError::from(NgsiError::ResourceNotFound(format!("tenant {raw}")))
    1109            2 :                 .into_response()
    1110              :         }
    1111            0 :         Err(e) => return ApiError::from(e).into_response(),
    1112              :     };
    1113           20 :     let instances = st.temporal.attr_instance_count(&tenant).await.unwrap_or(0);
    1114           20 :     let mut row = serde_json::json!({
    1115           20 :         "tenant": stats.tenant,
    1116           20 :         "counts": {
    1117           20 :             "entities": stats.entities,
    1118           20 :             "subscriptions": stats.subscriptions,
    1119           20 :             "csourceSubscriptions": stats.csource_subscriptions,
    1120           20 :             "registrations": stats.registrations,
    1121           20 :             "snapshots": stats.snapshots,
    1122           20 :             "entityMaps": stats.entity_maps,
    1123           20 :             "distSubs": stats.dist_subs,
    1124           20 :             "attrInstances": instances,
    1125              :         },
    1126              :     });
    1127           20 :     if let Some(c) = stats.created_at {
    1128            0 :         row["createdAt"] = serde_json::Value::String(c);
    1129           20 :     }
    1130           20 :     (StatusCode::OK, axum::Json(row)).into_response()
    1131           32 : }
    1132              : 
    1133              : /// Purge one tenant from both backends: 400 for a name no request could
    1134              : /// carry, 404 when it does not exist (5.5.10), 409 while it still holds
    1135              : /// distributed subscriptions (unsubscribe them first), 204 once every
    1136              : /// document of it is gone. Deleted subscriptions and registrations are
    1137              : /// announced to the bus mirrors the way an API delete is.
    1138           44 : async fn tenant_purge(
    1139           44 :     axum::extract::State(st): axum::extract::State<AppState>,
    1140           44 :     axum::extract::Path(raw): axum::extract::Path<String>,
    1141           44 : ) -> Response {
    1142              :     use crate::negotiate::ApiError;
    1143              :     use antares_store::Kind;
    1144           44 :     let bad = || {
    1145           18 :         ApiError::from(NgsiError::BadRequestData(format!(
    1146           18 :             "invalid tenant: {raw:?}"
    1147           18 :         )))
    1148           18 :         .into_response()
    1149           18 :     };
    1150           44 :     if reserved_tenant(&raw) {
    1151            8 :         return bad();
    1152           36 :     }
    1153           36 :     let Ok(tenant) = TenantId::new(&raw) else {
    1154           10 :         return bad();
    1155              :     };
    1156           26 :     match st.store.tenant_exists(&tenant).await {
    1157           22 :         Ok(true) => {}
    1158              :         Ok(false) => {
    1159            4 :             return ApiError::from(NgsiError::ResourceNotFound(format!("tenant {raw}")))
    1160            4 :                 .into_response()
    1161              :         }
    1162            0 :         Err(e) => return ApiError::from(e).into_response(),
    1163              :     }
    1164              :     // The paged walk, not `list`: the all-at-once read carries a ceiling
    1165              :     // (the Postgres arm refuses past MAX_UNDECIDED_ROWS), and a purge behind
    1166              :     // that ceiling means the Tenants most worth reclaiming are the ones that
    1167              :     // can never be reclaimed — while their rows stay readable to anyone
    1168              :     // sending the Tenant header.
    1169           60 :     async fn ids(st: &AppState, tenant: &TenantId, kind: Kind) -> Result<Vec<String>, NgsiError> {
    1170           60 :         let mut out = Vec::new();
    1171           60 :         crate::csource::walk_docs(st, tenant, kind, |doc| {
    1172           18 :             if let Some(id) = doc.get("id").and_then(|v| v.as_str()) {
    1173           18 :                 out.push(id.to_owned());
    1174           18 :             }
    1175           18 :             Ok(())
    1176           18 :         })
    1177           60 :         .await?;
    1178           60 :         Ok(out)
    1179           60 :     }
    1180           22 :     let run = async {
    1181              :         // 5.8.1.4 stores one mapping document per distributed Subscription;
    1182              :         // its `remotes` names the subscription copies that live AT context
    1183              :         // sources. Those are deleted at their source by deleting the
    1184              :         // Subscription (5.8.5.4), which a purge does not do, so a tenant
    1185              :         // still holding them is refused rather than orphaning them. The
    1186              :         // mapping shell of a Subscription no source has matched yet, and
    1187              :         // the internal Registration Subscription beside it, hold nothing
    1188              :         // remote and block nothing — the id-shaped read this used to do
    1189              :         // saw neither, since a mapping document carries no `id` member.
    1190           22 :         let mut remote_copies = false;
    1191           32 :         crate::csource::walk_docs(&st, &tenant, Kind::DistSub, |doc| {
    1192           30 :             if doc
    1193           30 :                 .get("remotes")
    1194           30 :                 .and_then(serde_json::Value::as_object)
    1195           30 :                 .is_some_and(|m| !m.is_empty())
    1196            2 :             {
    1197            2 :                 remote_copies = true;
    1198           28 :             }
    1199           30 :             Ok(())
    1200           30 :         })
    1201           22 :         .await?;
    1202           22 :         if remote_copies {
    1203            2 :             return Err(NgsiError::Conflict(
    1204            2 :                 "tenant holds active distributed subscriptions".into(),
    1205            2 :             ));
    1206           20 :         }
    1207           20 :         let subs = ids(&st, &tenant, Kind::Subscription).await?;
    1208           20 :         let csubs = ids(&st, &tenant, Kind::CSourceSubscription).await?;
    1209           20 :         let regs = ids(&st, &tenant, Kind::Registration).await?;
    1210              :         // 5.2.41: a Snapshot's isolated copy does not live under the Tenant.
    1211              :         // It lives under the synthetic tenant its internal `__tenant` member
    1212              :         // names, and the Snapshot document is the only pointer to it — so the
    1213              :         // two purges below would delete that pointer and leave the copy
    1214              :         // behind, reachable by nothing and freeable by nothing. Each snapshot
    1215              :         // goes through the same removal a DELETE of it does, first.
    1216           20 :         let mut snaps = Vec::new();
    1217           20 :         crate::csource::walk_docs(&st, &tenant, Kind::Snapshot, |meta| {
    1218            2 :             snaps.push(meta);
    1219            2 :             Ok(())
    1220            2 :         })
    1221           20 :         .await?;
    1222           20 :         for meta in &snaps {
    1223            2 :             if let Some(id) = meta.get("id").and_then(|v| v.as_str()) {
    1224            2 :                 crate::snapshots::snap_remove(&st, &tenant, id, meta).await;
    1225            0 :             }
    1226              :         }
    1227           20 :         st.temporal.purge_tenant(&tenant).await?;
    1228           20 :         st.store.purge_tenant(&tenant).await?;
    1229           20 :         if let Some(sync) = &st.sub_sync {
    1230              :             // Kept apart: the mirror entry is keyed per kind, so one id
    1231              :             // naming both a Subscription and a Context Source Registration
    1232              :             // Subscription needs a tombstone for each.
    1233           14 :             for id in &subs {
    1234           12 :                 sync(&tenant, Kind::Subscription, id, None);
    1235           12 :             }
    1236           14 :             for id in &csubs {
    1237            0 :                 sync(&tenant, Kind::CSourceSubscription, id, None);
    1238            0 :             }
    1239            6 :         }
    1240           20 :         if let Some(sync) = &st.reg_sync {
    1241            0 :             for id in &regs {
    1242            0 :                 sync(&tenant, id, None);
    1243            0 :             }
    1244           20 :         }
    1245           20 :         Ok::<(), NgsiError>(())
    1246           22 :     };
    1247           22 :     if let Err(e) = run.await {
    1248            2 :         return ApiError::from(e).into_response();
    1249           20 :     }
    1250              :     // 5.13.1: a Hosted @context belongs to the Tenant that stored it, and
    1251              :     // the row carries that owner inside the document — `jsonld_contexts`
    1252              :     // has no tenant column, so the tenant-keyed purge above cannot see it.
    1253           20 :     if let Err(e) = crate::contexts::purge_tenant(&st, &tenant).await {
    1254            0 :         return ApiError::from(e).into_response();
    1255           20 :     }
    1256           20 :     StatusCode::NO_CONTENT.into_response()
    1257           44 : }
    1258              : 
    1259              : /// Readiness, distinct from `/q/health` liveness: ready = not draining AND
    1260              : /// the store answers a trivial request AND (when bus=nats) the bus is
    1261              : /// connected. On a Postgres failover or a NATS partition the pod is still
    1262              : /// alive (liveness stays 200 — a restart fixes nothing) but must stop
    1263              : /// receiving traffic, so the readinessProbe points HERE.
    1264           12 : async fn ready(
    1265           12 :     axum::extract::State(state): axum::extract::State<AppState>,
    1266           12 : ) -> (StatusCode, axum::Json<serde_json::Value>) {
    1267           12 :     let draining = state.draining.load(std::sync::atomic::Ordering::Relaxed);
    1268           12 :     let store_ok = state.store.ping().await.is_ok();
    1269           12 :     let bus = state.bus_stats.as_ref().map(|b| b());
    1270           12 :     let bus_ok = bus
    1271           12 :         .as_ref()
    1272           12 :         .is_none_or(|b| b.get("connected").and_then(serde_json::Value::as_bool) == Some(true));
    1273           12 :     let ready = !draining && store_ok && bus_ok;
    1274           12 :     let mut body = serde_json::json!({
    1275           12 :         "status": if ready { "READY" } else { "NOT_READY" },
    1276           12 :         "store": store_ok,
    1277              :     });
    1278           12 :     if let Some(b) = bus {
    1279            4 :         body["bus"] = b;
    1280            8 :     }
    1281           12 :     let code = if ready {
    1282            8 :         StatusCode::OK
    1283              :     } else {
    1284            4 :         StatusCode::SERVICE_UNAVAILABLE
    1285              :     };
    1286           12 :     (code, axum::Json(body))
    1287           12 : }
    1288              : 
    1289              : /// CIM 009 5.15.1 / 6.33 — Context Source identity.
    1290              : /// 5.15.1 Retrieve Context Source Identity Information: the 5.2.40
    1291              : /// ContextSourceIdentity object for this source (per tenant in the
    1292              : /// multi-tenancy case). The NotImplemented arm of 5.15.1.4 is vacuous here —
    1293              : /// this broker can always supply its identity.
    1294           50 : async fn source_identity(
    1295           50 :     axum::extract::State(state): axum::extract::State<AppState>,
    1296           50 :     headers: HeaderMap,
    1297           50 : ) -> Response {
    1298           50 :     let go = async {
    1299           50 :         let tenant = tenant_from(&headers)?;
    1300           50 :         gate!(state, &tenant, &headers, "5.15.1").await?;
    1301           50 :         let accept = negotiate::parse_accept(&headers)?;
    1302           50 :         let ctx = state.loader.core();
    1303           50 :         let uptime = state.started.elapsed().as_secs();
    1304              :         // Table 5.2.40-1: contextSourceAlias, contextSourceUptime and
    1305              :         // contextSourceTimeAt are all cardinality 1. `hostAlias`/`uptime` were
    1306              :         // not spec members at all — neither expands to an NGSI-LD IRI, so the
    1307              :         // payload was not valid JSON-LD against the core context either.
    1308              :         // Table 5.2.40-1: in the multi-tenancy case the alias "shall be
    1309              :         // identifying a specific Tenant within a registered Context Source",
    1310              :         // so what this resource serves depends on NGSILD-Tenant — a peer
    1311              :         // retrieves it per tenant and registers it as `contextSourceAlias`.
    1312           50 :         let alias = crate::federation::alias_for(&state.host_alias, &tenant);
    1313           50 :         let body = serde_json::json!({
    1314           50 :             "id": format!("urn:ngsi-ld:ContextSourceIdentity:{alias}"),
    1315           50 :             "type": "ContextSourceIdentity",
    1316           50 :             "contextSourceAlias": alias,
    1317           50 :             "contextSourceUptime": format!("PT{uptime}S"),
    1318           50 :             "contextSourceTimeAt": crate::state::now_iso(),
    1319              :         });
    1320           50 :         Ok::<_, ApiError>(respond(
    1321           50 :             axum::http::StatusCode::OK,
    1322           50 :             body,
    1323           50 :             &ctx,
    1324           50 :             accept,
    1325           50 :             &tenant,
    1326           50 :         ))
    1327           50 :     };
    1328           50 :     go.await.unwrap_or_else(|e| e.into_response())
    1329           50 : }
    1330              : 
    1331              : /// PATCH/PUT on the entities collection: the entity id is missing — 400.
    1332            8 : async fn missing_entity_id(headers: HeaderMap) -> Response {
    1333            8 :     let tenant = tenant_from(&headers).unwrap_or_default();
    1334            8 :     let mut resp = ApiError::from(NgsiError::BadRequestData(
    1335            8 :         "entity id is required in the request path".into(),
    1336            8 :     ))
    1337            8 :     .into_response();
    1338            8 :     echo_tenant(&tenant, &mut resp);
    1339            8 :     resp
    1340            8 : }
    1341              : 
    1342           28 : async fn not_found(headers: HeaderMap, uri: axum::http::Uri) -> Response {
    1343           28 :     let path = uri.path().to_owned();
    1344           28 :     let tenant = tenant_from(&headers).unwrap_or_default();
    1345              :     // A path with an empty segment (…/attrs//{x}) names a resource whose
    1346              :     // methods don't apply — 405 per the suite (016_02_04/06).
    1347           28 :     if path.starts_with(API_ROOT) && path.contains("//") {
    1348            8 :         let mut resp = axum::http::StatusCode::METHOD_NOT_ALLOWED.into_response();
    1349            8 :         echo_tenant(&tenant, &mut resp);
    1350            8 :         return resp;
    1351           20 :     }
    1352           20 :     let mut resp =
    1353           20 :         ApiError::from(NgsiError::ResourceNotFound(format!("unknown path {path}"))).into_response();
    1354           20 :     echo_tenant(&tenant, &mut resp);
    1355           20 :     resp
    1356           28 : }
    1357              : 
    1358              : #[cfg(test)]
    1359              : mod tests {
    1360              :     use super::*;
    1361              :     use axum::body::Body;
    1362              :     use axum::http::{Request, StatusCode};
    1363              :     use http_body_util::BodyExt;
    1364              :     use tower::ServiceExt;
    1365              : 
    1366          248 :     fn app() -> Router {
    1367          248 :         router(AppState::new("antares-test".into()))
    1368          248 :     }
    1369              : 
    1370          160 :     async fn body_json(resp: Response) -> serde_json::Value {
    1371          160 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    1372          160 :         serde_json::from_slice(&bytes).expect("json body")
    1373          160 :     }
    1374              : 
    1375              :     /// The metrics method label is bounded: known HTTP methods pass
    1376              :     /// through, client-minted extension methods collapse to "OTHER" so
    1377              :     /// label cardinality cannot grow without bound.
    1378              :     #[test]
    1379            4 :     fn metric_method_label_is_bounded() {
    1380            4 :         assert_eq!(metric_method("GET"), "GET");
    1381            4 :         assert_eq!(metric_method("DELETE"), "DELETE");
    1382            4 :         assert_eq!(metric_method("BAZQUX"), "OTHER");
    1383              :         // methods are case-sensitive tokens (RFC 9110 9.1)
    1384            4 :         assert_eq!(metric_method("get"), "OTHER");
    1385            4 :     }
    1386              : 
    1387              :     #[tokio::test]
    1388            4 :     async fn health_is_up() {
    1389            4 :         let resp = app()
    1390            4 :             .oneshot(Request::get("/q/health").body(Body::empty()).expect("req"))
    1391            4 :             .await
    1392            4 :             .expect("resp");
    1393            4 :         assert_eq!(resp.status(), StatusCode::OK);
    1394            4 :         let body = body_json(resp).await;
    1395            4 :         assert_eq!(body["status"], "UP");
    1396              :         // Version surface. The commit must be the one this binary was BUILT
    1397              :         // from, so it is compared against the checkout rather than merely
    1398              :         // checked for being a non-empty string: a build script that stops
    1399              :         // rerunning bakes in a hash from an older commit, and /q/health then
    1400              :         // names a build that is not the one running. Outside a git checkout
    1401              :         // the build script reports "unknown" and there is nothing to compare.
    1402            4 :         assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
    1403            4 :         let head = std::process::Command::new("git")
    1404            4 :             .args(["rev-parse", "--short", "HEAD"])
    1405            4 :             .output()
    1406            4 :             .ok()
    1407            4 :             .filter(|o| o.status.success())
    1408            4 :             .and_then(|o| String::from_utf8(o.stdout).ok());
    1409            4 :         match head {
    1410            4 :             Some(h) => assert_eq!(body["commit"], h.trim(), "stale build hash"),
    1411            4 :             None => assert_eq!(body["commit"], "unknown"),
    1412            4 :         }
    1413            4 :     }
    1414              : 
    1415              :     /// /q/health names the temporal backend next to the store: the store's
    1416              :     /// own mode when one instance serves both seams, `none` for NoTemporal.
    1417              :     /// The deliverable endpoint schemes are the registered bindings and
    1418              :     /// nothing else, so a client can tell what a subscription may name.
    1419              :     #[tokio::test(flavor = "multi_thread")]
    1420            4 :     async fn health_lists_the_registered_notification_schemes() {
    1421            4 :         let st = AppState::new("h".into());
    1422            4 :         let (code, body) = health(axum::extract::State(st)).await;
    1423            4 :         assert_eq!(code, StatusCode::OK);
    1424            4 :         let schemes = body.0["notificationSchemes"]
    1425            4 :             .as_array()
    1426            4 :             .expect("notificationSchemes")
    1427            4 :             .iter()
    1428            4 :             .filter_map(serde_json::Value::as_str)
    1429            4 :             .collect::<Vec<_>>();
    1430            4 :         assert!(
    1431            4 :             schemes.contains(&"http") && schemes.contains(&"https"),
    1432              :             "{schemes:?}"
    1433              :         );
    1434              :         #[cfg(feature = "mqtt")]
    1435            4 :         assert!(
    1436            4 :             schemes.contains(&"mqtt") && schemes.contains(&"mqtts"),
    1437              :             "{schemes:?}"
    1438              :         );
    1439            4 :         assert!(
    1440            4 :             !schemes.contains(&"ws"),
    1441            4 :             "only registered bindings: {schemes:?}"
    1442            4 :         );
    1443            4 :     }
    1444              : 
    1445              :     #[tokio::test]
    1446            4 :     async fn health_names_the_temporal_backend() {
    1447            4 :         let body = body_json(
    1448            4 :             app()
    1449            4 :                 .oneshot(Request::get("/q/health").body(Body::empty()).expect("req"))
    1450            4 :                 .await
    1451            4 :                 .expect("resp"),
    1452              :         )
    1453            4 :         .await;
    1454              :         // whichever built-in store the harness composed, both seams name it
    1455            4 :         let store = body["store"].as_str().expect("store name").to_owned();
    1456            4 :         assert!(store == "memory" || store == "file", "store: {store}");
    1457            4 :         assert_eq!(body["temporal"], store);
    1458              : 
    1459            4 :         let st = AppState::with_drivers(
    1460            4 :             "antares-test".into(),
    1461            4 :             std::sync::Arc::new(antares_sql::store::any::AnyStore::Mem(
    1462            4 :                 antares_sql::store::Store::default(),
    1463            4 :             )),
    1464            4 :             std::sync::Arc::new(antares_store::NoTemporal),
    1465            4 :             "memory",
    1466              :         );
    1467            4 :         let body = body_json(
    1468            4 :             router(st)
    1469            4 :                 .oneshot(Request::get("/q/health").body(Body::empty()).expect("req"))
    1470            4 :                 .await
    1471            4 :                 .expect("resp"),
    1472              :         )
    1473            4 :         .await;
    1474            4 :         assert_eq!(body["store"], "memory");
    1475            4 :         assert_eq!(body["temporal"], "none");
    1476            4 :     }
    1477              : 
    1478              :     /// /q/health names the policy engine and the deadline one decision
    1479              :     /// gets, so an operator can tell an allow-all broker from one an addon
    1480              :     /// engine was registered into without reading the process environment.
    1481              :     #[tokio::test]
    1482            4 :     async fn health_names_the_policy_engine_and_its_timeout() {
    1483              :         use policy::PolicyEngine as _;
    1484            4 :         let body = body_json(
    1485            4 :             app()
    1486            4 :                 .oneshot(Request::get("/q/health").body(Body::empty()).expect("req"))
    1487            4 :                 .await
    1488            4 :                 .expect("resp"),
    1489              :         )
    1490            4 :         .await;
    1491            4 :         assert_eq!(body["policy"]["engine"], policy::AllowAll.name());
    1492            4 :         assert_eq!(
    1493            4 :             body["policy"]["timeoutMs"],
    1494            4 :             u64::try_from(policy::TIMEOUT.as_millis()).unwrap_or(u64::MAX)
    1495              :         );
    1496              : 
    1497              :         struct Named;
    1498              :         impl policy::PolicyEngine for Named {
    1499            4 :             fn name(&self) -> &str {
    1500            4 :                 "named-engine"
    1501            4 :             }
    1502            0 :             fn decide<'a>(
    1503            0 :                 &'a self,
    1504            0 :                 _s: &'a policy::Subject,
    1505            0 :                 _o: &'a policy::Operation<'a>,
    1506            0 :             ) -> policy::DecisionFuture<'a> {
    1507            0 :                 Box::pin(async { policy::Decision::Allow })
    1508            0 :             }
    1509            0 :             fn pre_notify(
    1510            0 :                 &self,
    1511            0 :                 _s: &policy::Subject,
    1512            0 :                 _sub: &serde_json::Value,
    1513            0 :                 _n: &mut serde_json::Value,
    1514            0 :             ) -> policy::NotifyDecision {
    1515            0 :                 policy::NotifyDecision::Deliver
    1516            0 :             }
    1517              :         }
    1518            4 :         let st = AppState::new("h".into()).with_policy(std::sync::Arc::new(Named));
    1519            4 :         let (_, body) = health(axum::extract::State(st)).await;
    1520            4 :         assert_eq!(body.0["policy"]["engine"], "named-engine");
    1521            4 :     }
    1522              : 
    1523              :     /// /q/health reports the bus — `bus: {mode,
    1524              :     /// connected, reconnects}` when the nats wiring installed bus_stats,
    1525              :     /// and the field is ABSENT for bus=local (no stats installed).
    1526              :     #[tokio::test]
    1527            4 :     async fn health_reports_the_bus_only_when_nats_is_wired() {
    1528              :         // bus=local: no bus_stats ⇒ no `bus` member at all
    1529            4 :         let resp = app()
    1530            4 :             .oneshot(Request::get("/q/health").body(Body::empty()).expect("req"))
    1531            4 :             .await
    1532            4 :             .expect("resp");
    1533            4 :         let body = body_json(resp).await;
    1534            4 :         assert!(
    1535            4 :             body.get("bus").is_none(),
    1536              :             "bus member must be ABSENT for bus=local: {body}"
    1537              :         );
    1538              : 
    1539              :         // nats wiring installs the closure ⇒ the live state is reported
    1540            4 :         let mut st = AppState::new("antares-test".into());
    1541            4 :         st.bus_stats = Some(std::sync::Arc::new(
    1542            4 :             || serde_json::json!({"mode": "nats", "connected": true, "reconnects": 2}),
    1543              :         ));
    1544            4 :         let resp = router(st)
    1545            4 :             .oneshot(Request::get("/q/health").body(Body::empty()).expect("req"))
    1546            4 :             .await
    1547            4 :             .expect("resp");
    1548            4 :         let body = body_json(resp).await;
    1549            4 :         assert_eq!(body["bus"]["mode"], "nats");
    1550            4 :         assert_eq!(body["bus"]["connected"], true);
    1551            4 :         assert_eq!(body["bus"]["reconnects"], 2);
    1552            4 :     }
    1553              : 
    1554              :     /// /q/ready is READINESS — 200 on a healthy store, 503 the
    1555              :     /// moment the bus reports disconnected (a pod that cannot process must
    1556              :     /// stop receiving traffic while staying alive for liveness).
    1557              :     #[tokio::test]
    1558            4 :     async fn ready_gates_on_store_and_bus() {
    1559            4 :         let resp = app()
    1560            4 :             .oneshot(Request::get("/q/ready").body(Body::empty()).expect("req"))
    1561            4 :             .await
    1562            4 :             .expect("resp");
    1563            4 :         assert_eq!(resp.status(), StatusCode::OK);
    1564            4 :         assert_eq!(body_json(resp).await["status"], "READY");
    1565              : 
    1566            4 :         let mut st = AppState::new("antares-test".into());
    1567            4 :         st.bus_stats = Some(std::sync::Arc::new(
    1568            4 :             || serde_json::json!({"mode": "nats", "connected": false, "reconnects": 3}),
    1569              :         ));
    1570            4 :         let resp = router(st)
    1571            4 :             .oneshot(Request::get("/q/ready").body(Body::empty()).expect("req"))
    1572            4 :             .await
    1573            4 :             .expect("resp");
    1574            4 :         assert_eq!(
    1575            4 :             resp.status(),
    1576              :             StatusCode::SERVICE_UNAVAILABLE,
    1577              :             "a disconnected bus must flip readiness"
    1578              :         );
    1579            4 :         assert_eq!(body_json(resp).await["status"], "NOT_READY");
    1580            4 :     }
    1581              : 
    1582              :     /// The operations of `docs/openapi/antares-admin.yaml`, read with a
    1583              :     /// line scan: an OpenAPI path is a two-space-indented key under
    1584              :     /// `paths:`, a method a four-space-indented HTTP verb under it. The
    1585              :     /// document is ours and hand-written, so its layout is fixed.
    1586            4 :     fn documented_operations() -> Vec<(String, Vec<&'static str>)> {
    1587            4 :         let path = concat!(
    1588              :             env!("CARGO_MANIFEST_DIR"),
    1589              :             "/../../docs/openapi/antares-admin.yaml"
    1590              :         );
    1591            4 :         let text = std::fs::read_to_string(path).expect("antares-admin.yaml beside the crates");
    1592            4 :         let mut ops: Vec<(String, Vec<&'static str>)> = Vec::new();
    1593            4 :         let mut in_paths = false;
    1594         2440 :         for line in text.lines() {
    1595         2440 :             if line == "paths:" {
    1596            4 :                 in_paths = true;
    1597         2436 :             } else if !line.starts_with(' ') && !line.is_empty() {
    1598           32 :                 in_paths = false;
    1599         2404 :             }
    1600         2440 :             if !in_paths {
    1601         1324 :                 continue;
    1602         1116 :             }
    1603         1116 :             let key = line.trim_start();
    1604         1116 :             let indent = line.len() - key.len();
    1605         1116 :             let Some(key) = key.strip_suffix(':') else {
    1606          656 :                 continue;
    1607              :             };
    1608          460 :             if indent == 2 && key.starts_with('/') {
    1609           36 :                 ops.push((key.to_owned(), Vec::new()));
    1610          424 :             } else if indent == 4 {
    1611           52 :                 if let Some(last) = ops.last_mut() {
    1612           52 :                     if let Some(m) = ["get", "post", "put", "patch", "delete"]
    1613           52 :                         .into_iter()
    1614          140 :                         .find(|m| *m == key)
    1615           40 :                     {
    1616           40 :                         last.1.push(m);
    1617           40 :                     }
    1618            0 :                 }
    1619          372 :             }
    1620              :         }
    1621            4 :         ops
    1622            4 :     }
    1623              : 
    1624              :     /// The operational OpenAPI document and the router describe the same
    1625              :     /// surface. Paths: the document lists exactly the admin surface plus
    1626              :     /// the 5.8.1.4 peer wire. Methods, proven from outside on the live
    1627              :     /// router: a documented method is never answered 405, and a method
    1628              :     /// the document omits always is. A route added on one side alone
    1629              :     /// fails here.
    1630              :     #[tokio::test]
    1631            4 :     async fn operational_openapi_matches_the_router() {
    1632            4 :         let ops = documented_operations();
    1633           36 :         let mut documented: Vec<&str> = ops.iter().map(|(p, _)| p.as_str()).collect();
    1634            4 :         documented.sort_unstable();
    1635            4 :         let mut mounted: Vec<String> = Admin::PATHS
    1636            4 :             .iter()
    1637           32 :             .map(|p| format!("{}{p}", Admin.prefix()))
    1638            4 :             .collect();
    1639            4 :         mounted.push("/ex/v1/remote-notify".into());
    1640            4 :         mounted.sort_unstable();
    1641            4 :         assert_eq!(
    1642              :             documented, mounted,
    1643              :             "paths in antares-admin.yaml vs the router"
    1644              :         );
    1645              : 
    1646            4 :         let app = app();
    1647           36 :         for (path, methods) in &ops {
    1648           36 :             assert!(!methods.is_empty(), "{path} documents no method");
    1649           36 :             let concrete = path.replace("{tenant}", "default").replace("{id}", "x");
    1650          180 :             for m in ["get", "post", "put", "patch", "delete"] {
    1651          180 :                 let resp = app
    1652          180 :                     .clone()
    1653          180 :                     .oneshot(
    1654          180 :                         // an explicit zero length: the body layer answers
    1655          180 :                         // 411 to a length-less write before routing
    1656          180 :                         Request::builder()
    1657          180 :                             .method(m.to_ascii_uppercase().as_str())
    1658          180 :                             .uri(&concrete)
    1659          180 :                             .header(axum::http::header::CONTENT_LENGTH, "0")
    1660          180 :                             .body(Body::empty())
    1661          180 :                             .expect("req"),
    1662          180 :                     )
    1663          180 :                     .await
    1664          180 :                     .expect("resp");
    1665          180 :                 let refused = resp.status() == StatusCode::METHOD_NOT_ALLOWED;
    1666          180 :                 assert_eq!(
    1667            4 :                     refused,
    1668          180 :                     !methods.contains(&m),
    1669            4 :                     "{m} {path}: documented={} answered {}",
    1670            4 :                     methods.contains(&m),
    1671            4 :                     resp.status()
    1672            4 :                 );
    1673            4 :             }
    1674            4 :         }
    1675            4 :     }
    1676              : 
    1677              :     /// A pod without the api role serves ops endpoints ONLY —
    1678              :     /// the NGSI-LD surface must NOT be reachable on it.
    1679              :     #[tokio::test]
    1680            4 :     async fn ops_router_serves_no_ngsi_ld_surface() {
    1681            4 :         let st = AppState::new("antares-test".into());
    1682            4 :         let app = ops_router(st);
    1683            4 :         let resp = app
    1684            4 :             .clone()
    1685            4 :             .oneshot(Request::get("/q/health").body(Body::empty()).expect("req"))
    1686            4 :             .await
    1687            4 :             .expect("resp");
    1688            4 :         assert_eq!(resp.status(), StatusCode::OK);
    1689            8 :         for path in ["/ngsi-ld/v1/entities", "/ngsi-ld/v1/subscriptions"] {
    1690            8 :             let resp = app
    1691            8 :                 .clone()
    1692            8 :                 .oneshot(Request::get(path).body(Body::empty()).expect("req"))
    1693            8 :                 .await
    1694            8 :                 .expect("resp");
    1695            8 :             assert_eq!(
    1696            8 :                 resp.status(),
    1697            4 :                 StatusCode::NOT_FOUND,
    1698            4 :                 "{path} must not exist on a worker pod"
    1699            4 :             );
    1700            4 :         }
    1701            4 :     }
    1702              : 
    1703              :     /// 5.6.2.4: "no existing Entity whose id (URI), and where specified
    1704              :     /// type, is equivalent … an error of type ResourceNotFound shall be
    1705              :     /// raised" — the optional ?type selector (4.17) narrows the target.
    1706              :     #[tokio::test]
    1707            4 :     async fn clause_5_6_2_type_selector_gates_the_update() {
    1708            4 :         let app = app();
    1709            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:sel", "type": "Building",
    1710            4 :             "name": {"type": "Property", "value": "x"}});
    1711            4 :         let body = entity.to_string();
    1712            4 :         let resp = app
    1713            4 :             .clone()
    1714            4 :             .oneshot(
    1715            4 :                 Request::post("/ngsi-ld/v1/entities")
    1716            4 :                     .header("Content-Type", "application/json")
    1717            4 :                     .header("Content-Length", body.len())
    1718            4 :                     .body(Body::from(body))
    1719            4 :                     .expect("req"),
    1720            4 :             )
    1721            4 :             .await
    1722            4 :             .expect("resp");
    1723            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    1724            8 :         let patch = |ty: &str| {
    1725            8 :             let frag = serde_json::json!({"name": {"type": "Property", "value": "y"}}).to_string();
    1726            8 :             Request::patch(format!(
    1727              :                 "/ngsi-ld/v1/entities/urn:ngsi-ld:B:sel/attrs?type={ty}"
    1728              :             ))
    1729            8 :             .header("Content-Type", "application/json")
    1730            8 :             .header("Content-Length", frag.len())
    1731            8 :             .body(Body::from(frag))
    1732            8 :             .expect("req")
    1733            8 :         };
    1734              :         // wrong type: the target is "not known" under this selector → 404
    1735            4 :         let resp = app.clone().oneshot(patch("Vehicle")).await.expect("resp");
    1736            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    1737              :         // matching type: update proceeds
    1738            4 :         let resp = app.clone().oneshot(patch("Building")).await.expect("resp");
    1739            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    1740              :         // and the wrong-type attempt must NOT have written anything
    1741            4 :         let resp = app
    1742            4 :             .oneshot(
    1743            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:sel")
    1744            4 :                     .body(Body::empty())
    1745            4 :                     .expect("req"),
    1746            4 :             )
    1747            4 :             .await
    1748            4 :             .expect("resp");
    1749            4 :         let doc = body_json(resp).await;
    1750            4 :         assert_eq!(doc["name"]["value"], "y");
    1751            4 :     }
    1752              : 
    1753              :     /// 5.6.3.4: append honours the ?type selector the same way — a
    1754              :     /// mismatching selector means the entity is not known (404); overwrite
    1755              :     /// vs noOverwrite semantics are 5.5.8's merge_instance_sets.
    1756              :     #[tokio::test]
    1757            4 :     async fn clause_5_6_3_type_selector_gates_the_append() {
    1758            4 :         let app = app();
    1759            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:app", "type": "Building"});
    1760            4 :         let body = entity.to_string();
    1761            4 :         let resp = app
    1762            4 :             .clone()
    1763            4 :             .oneshot(
    1764            4 :                 Request::post("/ngsi-ld/v1/entities")
    1765            4 :                     .header("Content-Type", "application/json")
    1766            4 :                     .header("Content-Length", body.len())
    1767            4 :                     .body(Body::from(body))
    1768            4 :                     .expect("req"),
    1769            4 :             )
    1770            4 :             .await
    1771            4 :             .expect("resp");
    1772            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    1773            8 :         let append = |ty: &str| {
    1774            8 :             let frag = serde_json::json!({"name": {"type": "Property", "value": "z"}}).to_string();
    1775            8 :             Request::post(format!(
    1776              :                 "/ngsi-ld/v1/entities/urn:ngsi-ld:B:app/attrs?type={ty}"
    1777              :             ))
    1778            8 :             .header("Content-Type", "application/json")
    1779            8 :             .header("Content-Length", frag.len())
    1780            8 :             .body(Body::from(frag))
    1781            8 :             .expect("req")
    1782            8 :         };
    1783            4 :         let resp = app.clone().oneshot(append("Vehicle")).await.expect("resp");
    1784            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND, "wrong type selector");
    1785            4 :         let resp = app.clone().oneshot(append("Building")).await.expect("resp");
    1786            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    1787            4 :         let resp = app
    1788            4 :             .oneshot(
    1789            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:app")
    1790            4 :                     .body(Body::empty())
    1791            4 :                     .expect("req"),
    1792            4 :             )
    1793            4 :             .await
    1794            4 :             .expect("resp");
    1795            4 :         let doc = body_json(resp).await;
    1796            4 :         assert_eq!(doc["name"]["value"], "z");
    1797            4 :     }
    1798              : 
    1799              :     /// 5.6.4.4: "If the target Attribute is scope, then an error of type
    1800              :     /// BadRequestData shall be raised"; the ?type selector narrows the
    1801              :     /// target entity (404 on mismatch).
    1802              :     #[tokio::test]
    1803            4 :     async fn clause_5_6_4_scope_target_and_type_selector() {
    1804            4 :         let app = app();
    1805            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:pu", "type": "Building",
    1806            4 :             "scope": "/Madrid", "name": {"type": "Property", "value": "x"}});
    1807            4 :         let body = entity.to_string();
    1808            4 :         let resp = app
    1809            4 :             .clone()
    1810            4 :             .oneshot(
    1811            4 :                 Request::post("/ngsi-ld/v1/entities")
    1812            4 :                     .header("Content-Type", "application/json")
    1813            4 :                     .header("Content-Length", body.len())
    1814            4 :                     .body(Body::from(body))
    1815            4 :                     .expect("req"),
    1816            4 :             )
    1817            4 :             .await
    1818            4 :             .expect("resp");
    1819            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    1820           12 :         let patch = |attr: &str, q: &str| {
    1821           12 :             let frag = serde_json::json!({"value": "y"}).to_string();
    1822           12 :             Request::patch(format!(
    1823              :                 "/ngsi-ld/v1/entities/urn:ngsi-ld:B:pu/attrs/{attr}{q}"
    1824              :             ))
    1825           12 :             .header("Content-Type", "application/json")
    1826           12 :             .header("Content-Length", frag.len())
    1827           12 :             .body(Body::from(frag))
    1828           12 :             .expect("req")
    1829           12 :         };
    1830              :         // the scope pseudo-attribute is not partially updatable
    1831            4 :         let resp = app.clone().oneshot(patch("scope", "")).await.expect("resp");
    1832            4 :         assert_eq!(
    1833            4 :             resp.status(),
    1834              :             StatusCode::BAD_REQUEST,
    1835              :             "scope target is 400"
    1836              :         );
    1837              :         // type selector gates the partial update
    1838            4 :         let resp = app
    1839            4 :             .clone()
    1840            4 :             .oneshot(patch("name", "?type=Vehicle"))
    1841            4 :             .await
    1842            4 :             .expect("resp");
    1843            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    1844            4 :         let resp = app
    1845            4 :             .clone()
    1846            4 :             .oneshot(patch("name", "?type=Building"))
    1847            4 :             .await
    1848            4 :             .expect("resp");
    1849            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    1850            4 :         let resp = app
    1851            4 :             .oneshot(
    1852            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:pu")
    1853            4 :                     .body(Body::empty())
    1854            4 :                     .expect("req"),
    1855            4 :             )
    1856            4 :             .await
    1857            4 :             .expect("resp");
    1858            4 :         let doc = body_json(resp).await;
    1859            4 :         assert_eq!(doc["name"]["value"], "y");
    1860            4 :         assert_eq!(doc["scope"], "/Madrid", "scope untouched");
    1861            4 :     }
    1862              : 
    1863              :     /// 5.6.5.4: the ?type selector narrows the delete target — mismatch
    1864              :     /// means the entity is not known (404) and nothing is deleted.
    1865              :     #[tokio::test]
    1866            4 :     async fn clause_5_6_5_type_selector_gates_the_delete() {
    1867            4 :         let app = app();
    1868            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:del", "type": "Building",
    1869            4 :             "name": {"type": "Property", "value": "x"}});
    1870            4 :         let body = entity.to_string();
    1871            4 :         let resp = app
    1872            4 :             .clone()
    1873            4 :             .oneshot(
    1874            4 :                 Request::post("/ngsi-ld/v1/entities")
    1875            4 :                     .header("Content-Type", "application/json")
    1876            4 :                     .header("Content-Length", body.len())
    1877            4 :                     .body(Body::from(body))
    1878            4 :                     .expect("req"),
    1879            4 :             )
    1880            4 :             .await
    1881            4 :             .expect("resp");
    1882            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    1883            8 :         let del = |q: &str| {
    1884            8 :             Request::delete(format!(
    1885              :                 "/ngsi-ld/v1/entities/urn:ngsi-ld:B:del/attrs/name{q}"
    1886              :             ))
    1887            8 :             .body(Body::empty())
    1888            8 :             .expect("req")
    1889            8 :         };
    1890            4 :         let resp = app
    1891            4 :             .clone()
    1892            4 :             .oneshot(del("?type=Vehicle"))
    1893            4 :             .await
    1894            4 :             .expect("resp");
    1895            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    1896              :         // the attribute must still exist after the gated attempt
    1897            4 :         let resp = app
    1898            4 :             .clone()
    1899            4 :             .oneshot(
    1900            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:del")
    1901            4 :                     .body(Body::empty())
    1902            4 :                     .expect("req"),
    1903            4 :             )
    1904            4 :             .await
    1905            4 :             .expect("resp");
    1906            4 :         assert_eq!(body_json(resp).await["name"]["value"], "x");
    1907            4 :         let resp = app
    1908            4 :             .clone()
    1909            4 :             .oneshot(del("?type=Building"))
    1910            4 :             .await
    1911            4 :             .expect("resp");
    1912            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    1913            4 :         let resp = app
    1914            4 :             .oneshot(
    1915            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:del")
    1916            4 :                     .body(Body::empty())
    1917            4 :                     .expect("req"),
    1918            4 :             )
    1919            4 :             .await
    1920            4 :             .expect("resp");
    1921            4 :         assert!(
    1922            4 :             body_json(resp).await.get("name").is_none(),
    1923            4 :             "attribute deleted under the matching selector"
    1924            4 :         );
    1925            4 :     }
    1926              : 
    1927              :     /// 5.6.6.4: the ?type selector narrows the delete-entity target — a
    1928              :     /// mismatch means the entity is not known (404) and nothing is deleted.
    1929              :     #[tokio::test]
    1930            4 :     async fn clause_5_6_6_type_selector_gates_entity_delete() {
    1931            4 :         let app = app();
    1932            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:edel", "type": "Building"});
    1933            4 :         let body = entity.to_string();
    1934            4 :         let resp = app
    1935            4 :             .clone()
    1936            4 :             .oneshot(
    1937            4 :                 Request::post("/ngsi-ld/v1/entities")
    1938            4 :                     .header("Content-Type", "application/json")
    1939            4 :                     .header("Content-Length", body.len())
    1940            4 :                     .body(Body::from(body))
    1941            4 :                     .expect("req"),
    1942            4 :             )
    1943            4 :             .await
    1944            4 :             .expect("resp");
    1945            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    1946            8 :         let del = |q: &str| {
    1947            8 :             Request::delete(format!("/ngsi-ld/v1/entities/urn:ngsi-ld:B:edel{q}"))
    1948            8 :                 .body(Body::empty())
    1949            8 :                 .expect("req")
    1950            8 :         };
    1951            4 :         let resp = app
    1952            4 :             .clone()
    1953            4 :             .oneshot(del("?type=Vehicle"))
    1954            4 :             .await
    1955            4 :             .expect("resp");
    1956            4 :         assert_eq!(
    1957            4 :             resp.status(),
    1958              :             StatusCode::NOT_FOUND,
    1959              :             "wrong selector is 404"
    1960              :         );
    1961            4 :         let resp = app
    1962            4 :             .clone()
    1963            4 :             .oneshot(
    1964            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:edel")
    1965            4 :                     .body(Body::empty())
    1966            4 :                     .expect("req"),
    1967            4 :             )
    1968            4 :             .await
    1969            4 :             .expect("resp");
    1970            4 :         assert_eq!(
    1971            4 :             resp.status(),
    1972              :             StatusCode::OK,
    1973              :             "entity survives the gated delete"
    1974              :         );
    1975            4 :         let resp = app
    1976            4 :             .clone()
    1977            4 :             .oneshot(del("?type=Building"))
    1978            4 :             .await
    1979            4 :             .expect("resp");
    1980            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    1981            4 :         let resp = app
    1982            4 :             .oneshot(
    1983            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:edel")
    1984            4 :                     .body(Body::empty())
    1985            4 :                     .expect("req"),
    1986            4 :             )
    1987            4 :             .await
    1988            4 :             .expect("resp");
    1989            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    1990            4 :     }
    1991              : 
    1992              :     /// 5.6.7.4: "If the input Array is empty or contains a null value in
    1993              :     /// any of its items an error of type BadRequestData shall be raised" —
    1994              :     /// the WHOLE request fails, nothing is created.
    1995              :     #[tokio::test]
    1996            4 :     async fn clause_5_6_7_null_item_fails_the_whole_batch() {
    1997            4 :         let app = app();
    1998            4 :         let body = r#"[{"id":"urn:ngsi-ld:V:nb1","type":"Vehicle"}, null]"#;
    1999            4 :         let resp = app
    2000            4 :             .clone()
    2001            4 :             .oneshot(
    2002            4 :                 Request::post("/ngsi-ld/v1/entityOperations/create")
    2003            4 :                     .header("Content-Type", "application/json")
    2004            4 :                     .header("Content-Length", body.len())
    2005            4 :                     .body(Body::from(body))
    2006            4 :                     .expect("req"),
    2007            4 :             )
    2008            4 :             .await
    2009            4 :             .expect("resp");
    2010            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    2011              :         // and the non-null sibling must NOT have been created
    2012            4 :         let resp = app
    2013            4 :             .oneshot(
    2014            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:V:nb1")
    2015            4 :                     .body(Body::empty())
    2016            4 :                     .expect("req"),
    2017            4 :             )
    2018            4 :             .await
    2019            4 :             .expect("resp");
    2020            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    2021              :         // the empty array is 400 too
    2022            4 :         let resp = crate::tests::app()
    2023            4 :             .oneshot(
    2024            4 :                 Request::post("/ngsi-ld/v1/entityOperations/create")
    2025            4 :                     .header("Content-Type", "application/json")
    2026            4 :                     .header("Content-Length", 2)
    2027            4 :                     .body(Body::from("[]"))
    2028            4 :                     .expect("req"),
    2029            4 :             )
    2030            4 :             .await
    2031            4 :             .expect("resp");
    2032            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    2033            4 :     }
    2034              : 
    2035              :     /// 5.6.11.4: an upsert onto an existing Temporal Evolution adds the
    2036              :     /// instances AND unions new Entity Type names; a deletion-null instance
    2037              :     /// (the 4.5.7 representation) is legal temporal input.
    2038              :     #[tokio::test]
    2039            4 :     async fn clause_5_6_11_temporal_upsert_type_union_and_null_instances() {
    2040            4 :         let app = app();
    2041            8 :         let post = |body: String| {
    2042            8 :             Request::post("/ngsi-ld/v1/temporal/entities")
    2043            8 :                 .header("Content-Type", "application/json")
    2044            8 :                 .header("Content-Length", body.len())
    2045            8 :                 .body(Body::from(body))
    2046            8 :                 .expect("req")
    2047            8 :         };
    2048            4 :         let first = serde_json::json!({
    2049            4 :             "id": "urn:ngsi-ld:V:tu", "type": "Vehicle",
    2050            4 :             "speed": [{"type": "Property", "value": 1,
    2051            4 :                        "observedAt": "2026-01-01T00:00:00Z"}]
    2052              :         });
    2053            4 :         let resp = app
    2054            4 :             .clone()
    2055            4 :             .oneshot(post(first.to_string()))
    2056            4 :             .await
    2057            4 :             .expect("resp");
    2058            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2059              :         // second upsert: new type name + a deleted-instance representation
    2060            4 :         let second = serde_json::json!({
    2061            4 :             "id": "urn:ngsi-ld:V:tu", "type": ["Vehicle", "Truck"],
    2062            4 :             "speed": [{"type": "Property", "value": "urn:ngsi-ld:null",
    2063            4 :                        "observedAt": "2026-01-02T00:00:00Z"}]
    2064              :         });
    2065            4 :         let resp = app
    2066            4 :             .clone()
    2067            4 :             .oneshot(post(second.to_string()))
    2068            4 :             .await
    2069            4 :             .expect("resp");
    2070            4 :         assert_eq!(
    2071            4 :             resp.status(),
    2072              :             StatusCode::NO_CONTENT,
    2073              :             "deletion-null instances are legal temporal input (4.5.7/5.5.4)"
    2074              :         );
    2075            4 :         let resp = app
    2076            4 :             .oneshot(
    2077            4 :                 Request::get("/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:V:tu?timerel=before&timeAt=2030-01-01T00:00:00Z")
    2078            4 :                     .body(Body::empty())
    2079            4 :                     .expect("req"),
    2080            4 :             )
    2081            4 :             .await
    2082            4 :             .expect("resp");
    2083            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2084            4 :         let doc = body_json(resp).await;
    2085            4 :         let types: Vec<&str> = doc["type"]
    2086            4 :             .as_array()
    2087            4 :             .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect())
    2088            4 :             .unwrap_or_else(|| vec![doc["type"].as_str().unwrap_or_default()]);
    2089            4 :         assert!(
    2090            4 :             types.contains(&"Vehicle") && types.contains(&"Truck"),
    2091              :             "type names are unioned: {:?}",
    2092            0 :             doc["type"]
    2093              :         );
    2094              :         // both instances present — the deletion representation included
    2095            4 :         let speed = doc["speed"].as_array().expect("speed instances");
    2096            4 :         assert_eq!(speed.len(), 2, "history keeps both instances: {doc}");
    2097            4 :     }
    2098              : 
    2099              :     /// 5.6.19.4: replacing scope is BadRequestData; the ?type selector
    2100              :     /// narrows the target (404 on mismatch, attribute untouched).
    2101              :     #[tokio::test]
    2102            4 :     async fn clause_5_6_19_replace_attr_scope_and_type_selector() {
    2103            4 :         let app = app();
    2104            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:ra", "type": "Building",
    2105            4 :             "scope": "/Madrid", "name": {"type": "Property", "value": "x"}});
    2106            4 :         let body = entity.to_string();
    2107            4 :         let resp = app
    2108            4 :             .clone()
    2109            4 :             .oneshot(
    2110            4 :                 Request::post("/ngsi-ld/v1/entities")
    2111            4 :                     .header("Content-Type", "application/json")
    2112            4 :                     .header("Content-Length", body.len())
    2113            4 :                     .body(Body::from(body))
    2114            4 :                     .expect("req"),
    2115            4 :             )
    2116            4 :             .await
    2117            4 :             .expect("resp");
    2118            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2119           12 :         let put = |attr: &str, q: &str| {
    2120           12 :             let frag = serde_json::json!({"type": "Property", "value": "y"}).to_string();
    2121           12 :             Request::put(format!(
    2122              :                 "/ngsi-ld/v1/entities/urn:ngsi-ld:B:ra/attrs/{attr}{q}"
    2123              :             ))
    2124           12 :             .header("Content-Type", "application/json")
    2125           12 :             .header("Content-Length", frag.len())
    2126           12 :             .body(Body::from(frag))
    2127           12 :             .expect("req")
    2128           12 :         };
    2129            4 :         let resp = app.clone().oneshot(put("scope", "")).await.expect("resp");
    2130            4 :         assert_eq!(
    2131            4 :             resp.status(),
    2132              :             StatusCode::BAD_REQUEST,
    2133              :             "scope target is 400"
    2134              :         );
    2135            4 :         let resp = app
    2136            4 :             .clone()
    2137            4 :             .oneshot(put("name", "?type=Vehicle"))
    2138            4 :             .await
    2139            4 :             .expect("resp");
    2140            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    2141            4 :         let resp = app
    2142            4 :             .clone()
    2143            4 :             .oneshot(put("name", "?type=Building"))
    2144            4 :             .await
    2145            4 :             .expect("resp");
    2146            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    2147            4 :         let resp = app
    2148            4 :             .oneshot(
    2149            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:B:ra")
    2150            4 :                     .body(Body::empty())
    2151            4 :                     .expect("req"),
    2152            4 :             )
    2153            4 :             .await
    2154            4 :             .expect("resp");
    2155            4 :         let doc = body_json(resp).await;
    2156            4 :         assert_eq!(doc["name"]["value"], "y");
    2157            4 :         assert_eq!(doc["scope"], "/Madrid", "scope untouched");
    2158            4 :     }
    2159              : 
    2160              :     /// 5.5.11.1: in Batch Create the FIRST occurrence creates the entity,
    2161              :     /// any subsequent instance of the same id is reported as an error
    2162              :     /// (already exists). 5.5.11.4: in Batch Delete the first occurrence
    2163              :     /// deletes, subsequent ones report an error (does not exist).
    2164              :     #[tokio::test]
    2165            4 :     async fn clause_5_5_11_duplicate_ids_in_create_and_delete() {
    2166            4 :         let app = app();
    2167            4 :         let batch = serde_json::json!([
    2168            4 :             {"id": "urn:ngsi-ld:Building:c-dup", "type": "Building",
    2169            4 :              "speed": {"type": "Property", "value": 1}},
    2170            4 :             {"id": "urn:ngsi-ld:Building:c-dup", "type": "Building",
    2171            4 :              "speed": {"type": "Property", "value": 2}}
    2172              :         ]);
    2173            4 :         let resp = app
    2174            4 :             .clone()
    2175            4 :             .oneshot(
    2176            4 :                 Request::post("/ngsi-ld/v1/entityOperations/create")
    2177            4 :                     .header("Content-Type", "application/json")
    2178            4 :                     .header("Content-Length", batch.to_string().len())
    2179            4 :                     .body(Body::from(batch.to_string()))
    2180            4 :                     .expect("req"),
    2181            4 :             )
    2182            4 :             .await
    2183            4 :             .expect("resp");
    2184            4 :         assert_eq!(
    2185            4 :             resp.status(),
    2186              :             StatusCode::MULTI_STATUS,
    2187              :             "one ok + one error"
    2188              :         );
    2189            4 :         let body = body_json(resp).await;
    2190            4 :         assert_eq!(
    2191            4 :             body["success"],
    2192            4 :             serde_json::json!(["urn:ngsi-ld:Building:c-dup"])
    2193              :         );
    2194            4 :         assert_eq!(body["errors"].as_array().map(Vec::len), Some(1));
    2195            4 :         assert!(
    2196            4 :             body["errors"][0].to_string().contains("AlreadyExists"),
    2197              :             "second occurrence is an already-exists error: {body}"
    2198              :         );
    2199              :         // the FIRST occurrence created the entity
    2200            4 :         let resp = app
    2201            4 :             .clone()
    2202            4 :             .oneshot(
    2203            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:c-dup")
    2204            4 :                     .body(Body::empty())
    2205            4 :                     .expect("req"),
    2206            4 :             )
    2207            4 :             .await
    2208            4 :             .expect("resp");
    2209            4 :         let doc = body_json(resp).await;
    2210            4 :         assert_eq!(doc["speed"]["value"], 1, "first occurrence wins the create");
    2211              :         // batch delete with the id twice: first deletes, second errors
    2212            4 :         let del = serde_json::json!(["urn:ngsi-ld:Building:c-dup", "urn:ngsi-ld:Building:c-dup"]);
    2213            4 :         let resp = app
    2214            4 :             .clone()
    2215            4 :             .oneshot(
    2216            4 :                 Request::post("/ngsi-ld/v1/entityOperations/delete")
    2217            4 :                     .header("Content-Type", "application/json")
    2218            4 :                     .header("Content-Length", del.to_string().len())
    2219            4 :                     .body(Body::from(del.to_string()))
    2220            4 :                     .expect("req"),
    2221            4 :             )
    2222            4 :             .await
    2223            4 :             .expect("resp");
    2224            4 :         assert_eq!(resp.status(), StatusCode::MULTI_STATUS);
    2225            4 :         let body = body_json(resp).await;
    2226            4 :         assert_eq!(
    2227            4 :             body["success"],
    2228            4 :             serde_json::json!(["urn:ngsi-ld:Building:c-dup"])
    2229              :         );
    2230            4 :         assert!(
    2231            4 :             body["errors"][0].to_string().contains("ResourceNotFound"),
    2232              :             "second delete occurrence is a not-found error: {body}"
    2233              :         );
    2234              :         // and the entity is really gone
    2235            4 :         let resp = app
    2236            4 :             .oneshot(
    2237            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:c-dup")
    2238            4 :                     .body(Body::empty())
    2239            4 :                     .expect("req"),
    2240            4 :             )
    2241            4 :             .await
    2242            4 :             .expect("resp");
    2243            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    2244            4 :     }
    2245              : 
    2246              :     /// 5.5.11.0: "All Entities and Attributes in the batch will get the same
    2247              :     /// modifiedAt timestamp, so it makes sense to distinguish them via the
    2248              :     /// observedAt temporal property." One batch is one instant. Reading the
    2249              :     /// clock per entity spreads a large create over several milliseconds, and
    2250              :     /// a Context Consumer that filters or pages on modifiedAt then sees the
    2251              :     /// batch split in two — half of it before its own cursor, half after.
    2252              :     #[tokio::test]
    2253            4 :     async fn clause_5_5_11_0_one_batch_create_carries_one_timestamp() {
    2254            4 :         let app = app();
    2255              :         // Enough documents that per-entity stamping cannot stay inside one
    2256              :         // millisecond: the spread is what the clause forbids, not the count.
    2257            4 :         let batch: Vec<serde_json::Value> = (0..500)
    2258         2000 :             .map(|i| {
    2259         2000 :                 serde_json::json!({
    2260         2000 :                     "id": format!("urn:ngsi-ld:Building:ts-{i}"),
    2261         2000 :                     "type": "Building",
    2262         2000 :                     "speed": {"type": "Property", "value": i},
    2263         2000 :                     "name": {"type": "Property", "value": "x".repeat(64)},
    2264         2000 :                     "near": {"type": "Relationship",
    2265         2000 :                              "object": "urn:ngsi-ld:Building:other"},
    2266              :                 })
    2267         2000 :             })
    2268            4 :             .collect();
    2269            4 :         let body = serde_json::Value::Array(batch).to_string();
    2270            4 :         let resp = app
    2271            4 :             .clone()
    2272            4 :             .oneshot(
    2273            4 :                 Request::post("/ngsi-ld/v1/entityOperations/create")
    2274            4 :                     .header("Content-Type", "application/json")
    2275            4 :                     .header("Content-Length", body.len())
    2276            4 :                     .body(Body::from(body))
    2277            4 :                     .expect("req"),
    2278            4 :             )
    2279            4 :             .await
    2280            4 :             .expect("resp");
    2281            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2282            4 :         let resp = app
    2283            4 :             .oneshot(
    2284            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building&options=sysAttrs&limit=1000")
    2285            4 :                     .body(Body::empty())
    2286            4 :                     .expect("req"),
    2287            4 :             )
    2288            4 :             .await
    2289            4 :             .expect("resp");
    2290            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2291            4 :         let list = body_json(resp).await;
    2292            4 :         let ents = list.as_array().expect("entity array");
    2293            4 :         assert_eq!(ents.len(), 500, "every entity was created");
    2294              :         // Entity level and Attribute level: stamp_new writes the same instant
    2295              :         // into both, so one batch may produce exactly one value overall.
    2296            4 :         let mut stamps: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    2297         2000 :         for e in ents {
    2298         2000 :             let o = e.as_object().expect("entity object");
    2299         4000 :             for key in ["createdAt", "modifiedAt"] {
    2300         4000 :                 stamps.insert(
    2301         4000 :                     o.get(key)
    2302         4000 :                         .and_then(serde_json::Value::as_str)
    2303         4000 :                         .unwrap_or_else(|| panic!("entity {key} is served under sysAttrs"))
    2304         4000 :                         .to_owned(),
    2305            4 :                 );
    2306            4 :             }
    2307        14000 :             for (k, v) in o {
    2308         8000 :                 if matches!(
    2309        14000 :                     k.as_str(),
    2310        14000 :                     "id" | "type" | "@context" | "createdAt" | "modifiedAt"
    2311            4 :                 ) {
    2312         8000 :                     continue;
    2313         6000 :                 }
    2314        12000 :                 for key in ["createdAt", "modifiedAt"] {
    2315        12000 :                     stamps.insert(
    2316        12000 :                         v.get(key)
    2317        12000 :                             .and_then(serde_json::Value::as_str)
    2318        12000 :                             .unwrap_or_else(|| panic!("attribute {k} carries {key}"))
    2319        12000 :                             .to_owned(),
    2320            4 :                     );
    2321            4 :                 }
    2322            4 :             }
    2323            4 :         }
    2324            4 :         assert_eq!(
    2325            4 :             stamps.len(),
    2326            4 :             1,
    2327            4 :             "one batch, one timestamp; got {} distinct: {:?}",
    2328            4 :             stamps.len(),
    2329            4 :             stamps
    2330            4 :         );
    2331            4 :     }
    2332              : 
    2333              :     /// 4.6.6: duplicate instances of one Entity in a batch array "shall come
    2334              :     /// in chronological order" — the broker applies them sequentially, so
    2335              :     /// the LAST occurrence's state wins, never the first.
    2336              :     #[tokio::test]
    2337            4 :     async fn batch_duplicate_instances_apply_in_array_order() {
    2338            4 :         let app = app();
    2339            4 :         let batch = serde_json::json!([
    2340            4 :             {"id": "urn:ngsi-ld:Building:dup", "type": "Building",
    2341            4 :              "speed": {"type": "Property", "value": 1},
    2342            4 :              "old": {"type": "Property", "value": true}},
    2343            4 :             {"id": "urn:ngsi-ld:Building:dup", "type": "Building",
    2344            4 :              "speed": {"type": "Property", "value": 2}}
    2345              :         ]);
    2346            4 :         let resp = app
    2347            4 :             .clone()
    2348            4 :             .oneshot(
    2349            4 :                 Request::post("/ngsi-ld/v1/entityOperations/upsert")
    2350            4 :                     .header("Content-Type", "application/json")
    2351            4 :                     .header("Content-Length", batch.to_string().len())
    2352            4 :                     .body(Body::from(batch.to_string()))
    2353            4 :                     .expect("req"),
    2354            4 :             )
    2355            4 :             .await
    2356            4 :             .expect("resp");
    2357            4 :         assert!(
    2358            4 :             resp.status() == StatusCode::CREATED || resp.status() == StatusCode::NO_CONTENT,
    2359              :             "upsert with duplicates succeeds: {}",
    2360            0 :             resp.status()
    2361              :         );
    2362            4 :         let resp = app
    2363            4 :             .clone()
    2364            4 :             .oneshot(
    2365            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:dup")
    2366            4 :                     .body(Body::empty())
    2367            4 :                     .expect("req"),
    2368            4 :             )
    2369            4 :             .await
    2370            4 :             .expect("resp");
    2371            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2372            4 :         let doc = body_json(resp).await;
    2373            4 :         assert_eq!(doc["speed"]["value"], 2, "later instance wins");
    2374            4 :         assert_ne!(doc["speed"]["value"], 1, "first instance must not survive");
    2375              :         // default upsert is REPLACE: the second instance replaced the first
    2376              :         // wholesale, so the first-only attribute is gone too
    2377            4 :         assert!(
    2378            4 :             doc.get("old").is_none(),
    2379            4 :             "replace semantics: first instance's attrs must not linger"
    2380            4 :         );
    2381            4 :     }
    2382              : 
    2383              :     /// 4.6.4 Supported Content: "implementations shall preserve the
    2384              :     /// representation of the content of the values provided by the context
    2385              :     /// information providers and return the original content" — the
    2386              :     /// script-injection characters < > " ' = ; ( ) are stored and served
    2387              :     /// verbatim, never HTML/unicode-escaped and never rejected.
    2388              :     #[tokio::test]
    2389            4 :     async fn dangerous_content_preserved_verbatim() {
    2390            4 :         let app = app();
    2391            4 :         let payload = "<script>alert('x')</script> \"quoted\" = ; ( )";
    2392            4 :         let entity = serde_json::json!({
    2393            4 :             "id": "urn:ngsi-ld:Building:content",
    2394            4 :             "type": "Building",
    2395            4 :             "note": {"type": "Property", "value": payload}
    2396              :         });
    2397            4 :         let resp = app
    2398            4 :             .clone()
    2399            4 :             .oneshot(
    2400            4 :                 Request::post("/ngsi-ld/v1/entities")
    2401            4 :                     .header("Content-Type", "application/json")
    2402            4 :                     .header("Content-Length", entity.to_string().len())
    2403            4 :                     .body(Body::from(entity.to_string()))
    2404            4 :                     .expect("req"),
    2405            4 :             )
    2406            4 :             .await
    2407            4 :             .expect("resp");
    2408            4 :         assert_eq!(resp.status(), StatusCode::CREATED, "content never rejected");
    2409            4 :         let resp = app
    2410            4 :             .clone()
    2411            4 :             .oneshot(
    2412            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:content")
    2413            4 :                     .body(Body::empty())
    2414            4 :                     .expect("req"),
    2415            4 :             )
    2416            4 :             .await
    2417            4 :             .expect("resp");
    2418            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2419            4 :         let raw = resp.into_body().collect().await.expect("body").to_bytes();
    2420            4 :         let text = std::str::from_utf8(&raw).expect("utf-8");
    2421              :         // original content, not an escaped rendering of it
    2422            4 :         assert!(!text.contains("&lt;"), "no HTML escaping");
    2423            4 :         assert!(!text.contains("\\u003c"), "no unicode escaping");
    2424            4 :         let doc: serde_json::Value = serde_json::from_str(text).expect("json");
    2425            4 :         assert_eq!(doc["note"]["value"], payload, "value returned verbatim");
    2426            4 :     }
    2427              : 
    2428              :     /// 4.6.1 Supported text encodings: UTF-8 JSON accepted and exposed;
    2429              :     /// a non-UTF-8 body is not valid JSON → InvalidRequest 400.
    2430              :     #[tokio::test]
    2431            4 :     async fn utf8_encoding_accepted_and_non_utf8_rejected() {
    2432            4 :         let app = app();
    2433              :         // multibyte UTF-8 round-trips byte-exact
    2434            4 :         let entity = serde_json::json!({
    2435            4 :             "id": "urn:ngsi-ld:Building:utf8",
    2436            4 :             "type": "Building",
    2437            4 :             "label": {"type": "Property", "value": "žltý kôň — 100 €"}
    2438              :         });
    2439            4 :         let resp = app
    2440            4 :             .clone()
    2441            4 :             .oneshot(
    2442            4 :                 Request::post("/ngsi-ld/v1/entities")
    2443            4 :                     .header("Content-Type", "application/json")
    2444            4 :                     .header("Content-Length", entity.to_string().len())
    2445            4 :                     .body(Body::from(entity.to_string()))
    2446            4 :                     .expect("req"),
    2447            4 :             )
    2448            4 :             .await
    2449            4 :             .expect("resp");
    2450            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2451            4 :         let resp = app
    2452            4 :             .clone()
    2453            4 :             .oneshot(
    2454            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:utf8")
    2455            4 :                     .body(Body::empty())
    2456            4 :                     .expect("req"),
    2457            4 :             )
    2458            4 :             .await
    2459            4 :             .expect("resp");
    2460            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2461            4 :         let raw = resp.into_body().collect().await.expect("body").to_bytes();
    2462            4 :         let text = std::str::from_utf8(&raw).expect("response is valid UTF-8");
    2463            4 :         assert!(!text.contains('\u{FFFD}'), "no mojibake in output");
    2464            4 :         let doc: serde_json::Value = serde_json::from_str(text).expect("json");
    2465            4 :         assert_eq!(doc["label"]["value"], "žltý kôň — 100 €");
    2466              : 
    2467              :         // invalid UTF-8 byte in the body → InvalidRequest, not BadRequestData
    2468            4 :         let mut bad = b"{\"id\": \"urn:ngsi-ld:Building:b\", \"type\": \"".to_vec();
    2469            4 :         bad.extend_from_slice(&[0xFF, 0xFE]);
    2470            4 :         bad.extend_from_slice(b"\"}");
    2471            4 :         let resp = app
    2472            4 :             .clone()
    2473            4 :             .oneshot(
    2474            4 :                 Request::post("/ngsi-ld/v1/entities")
    2475            4 :                     .header("Content-Type", "application/json")
    2476            4 :                     .header("Content-Length", bad.len())
    2477            4 :                     .body(Body::from(bad.clone()))
    2478            4 :                     .expect("req"),
    2479            4 :             )
    2480            4 :             .await
    2481            4 :             .expect("resp");
    2482            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    2483            4 :         let err = body_json(resp).await;
    2484            4 :         assert_eq!(
    2485            4 :             err["type"],
    2486              :             "https://uri.etsi.org/ngsi-ld/errors/InvalidRequest"
    2487              :         );
    2488            4 :         assert_ne!(
    2489            4 :             err["type"], "https://uri.etsi.org/ngsi-ld/errors/BadRequestData",
    2490            4 :             "syntactic (encoding) failure is InvalidRequest, not BadRequestData"
    2491            4 :         );
    2492            4 :     }
    2493              : 
    2494              :     #[tokio::test]
    2495            4 :     async fn prefer_ngsild_version_downgrades_and_203s() {
    2496              :         // 6.3.6/6.3.21 + 4.3.6.8: Prefer: ngsi-ld=1.4 on a retrieve of an
    2497              :         // entity holding a 1.8-era attribute type → amended payload,
    2498              :         // Preference-Applied echo, 203 Non-Authoritative.
    2499            4 :         let app = app();
    2500            4 :         let entity = serde_json::json!({
    2501            4 :             "id": "urn:ngsi-ld:Building:pref1",
    2502            4 :             "type": "Building",
    2503            4 :             "spec": {"type": "JsonProperty", "json": {"k": 1}}
    2504              :         });
    2505            4 :         let resp = app
    2506            4 :             .clone()
    2507            4 :             .oneshot(
    2508            4 :                 Request::post("/ngsi-ld/v1/entities")
    2509            4 :                     .header("Content-Type", "application/json")
    2510            4 :                     .header("Content-Length", (entity.to_string()).len())
    2511            4 :                     .body(Body::from(entity.to_string()))
    2512            4 :                     .expect("req"),
    2513            4 :             )
    2514            4 :             .await
    2515            4 :             .expect("resp");
    2516            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2517              : 
    2518            4 :         let resp = app
    2519            4 :             .clone()
    2520            4 :             .oneshot(
    2521            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:pref1")
    2522            4 :                     .header("Prefer", "ngsi-ld=1.4")
    2523            4 :                     .body(Body::empty())
    2524            4 :                     .expect("req"),
    2525            4 :             )
    2526            4 :             .await
    2527            4 :             .expect("resp");
    2528            4 :         assert_eq!(resp.status(), StatusCode::NON_AUTHORITATIVE_INFORMATION);
    2529            4 :         assert_eq!(
    2530            4 :             resp.headers()
    2531            4 :                 .get("Preference-Applied")
    2532            4 :                 .map(|v| v.to_str().unwrap()),
    2533              :             Some("ngsi-ld=1.4")
    2534              :         );
    2535            4 :         let doc = body_json(resp).await;
    2536            4 :         assert_eq!(
    2537            4 :             doc["spec"],
    2538            4 :             serde_json::json!({"type": "Property", "value": {"k": 1}})
    2539              :         );
    2540              : 
    2541              :         // Native-version preference: applied header, payload untouched, 200.
    2542            4 :         let resp = app
    2543            4 :             .clone()
    2544            4 :             .oneshot(
    2545            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:pref1")
    2546            4 :                     .header("Prefer", "ngsi-ld=1.9")
    2547            4 :                     .body(Body::empty())
    2548            4 :                     .expect("req"),
    2549            4 :             )
    2550            4 :             .await
    2551            4 :             .expect("resp");
    2552            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2553            4 :         assert_eq!(
    2554            4 :             resp.headers()
    2555            4 :                 .get("Preference-Applied")
    2556            4 :                 .map(|v| v.to_str().unwrap()),
    2557            4 :             Some("ngsi-ld=1.9")
    2558            4 :         );
    2559            4 :     }
    2560              : 
    2561              :     /// 6.3.4 answers an over-long URI with a bare 414 and an over-large body
    2562              :     /// with 413. Both are preconditions on the request itself, so they must
    2563              :     /// not be spent on — or masked by — the 5.5.10 tenant lookup.
    2564              :     #[tokio::test]
    2565            4 :     async fn the_bounds_wall_answers_before_the_tenant_lookup() {
    2566            4 :         let app = app();
    2567            4 :         let long = "x".repeat(crate::bounds::MAX_URI_BYTES + 1);
    2568            4 :         let resp = app
    2569            4 :             .clone()
    2570            4 :             .oneshot(
    2571            4 :                 Request::get(format!("/ngsi-ld/v1/entities?type=T&idPattern={long}"))
    2572            4 :                     .header("NGSILD-Tenant", "ghost")
    2573            4 :                     .body(Body::empty())
    2574            4 :                     .expect("req"),
    2575            4 :             )
    2576            4 :             .await
    2577            4 :             .expect("resp");
    2578            4 :         assert_eq!(
    2579            4 :             resp.status(),
    2580              :             StatusCode::URI_TOO_LONG,
    2581              :             "the URI precondition decides, not the unknown tenant"
    2582              :         );
    2583              : 
    2584            4 :         let big = vec![b'a'; *crate::bounds::MAX_BODY_BYTES + 1];
    2585            4 :         let resp = app
    2586            4 :             .oneshot(
    2587            4 :                 Request::post("/ngsi-ld/v1/entities")
    2588            4 :                     .header("Content-Type", "application/json")
    2589            4 :                     .header("NGSILD-Tenant", "ghost")
    2590            4 :                     .header("Content-Length", big.len())
    2591            4 :                     .body(Body::from(big))
    2592            4 :                     .expect("req"),
    2593            4 :             )
    2594            4 :             .await
    2595            4 :             .expect("resp");
    2596            4 :         assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
    2597            4 :     }
    2598              : 
    2599              :     #[tokio::test]
    2600            4 :     async fn bounds_wall_rejects_spec_shaped() {
    2601              :         // Every cap answers with the spec error, before any parse.
    2602            4 :         let app = app();
    2603              : 
    2604              :         // JSON nesting > 64 → 400 BadRequestData
    2605            4 :         let deep = format!(
    2606              :             r#"{{"id":"urn:x:1","type":"T","a":{}1{}}}"#,
    2607            4 :             "[".repeat(70),
    2608            4 :             "]".repeat(70)
    2609              :         );
    2610            4 :         let resp = app
    2611            4 :             .clone()
    2612            4 :             .oneshot(
    2613            4 :                 Request::post("/ngsi-ld/v1/entities")
    2614            4 :                     .header("Content-Type", "application/json")
    2615            4 :                     .header("Content-Length", (deep).len())
    2616            4 :                     .body(Body::from(deep))
    2617            4 :                     .expect("req"),
    2618            4 :             )
    2619            4 :             .await
    2620            4 :             .expect("resp");
    2621            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    2622              : 
    2623              :         // URI too long → bare 414
    2624            4 :         let resp = app
    2625            4 :             .clone()
    2626            4 :             .oneshot(
    2627            4 :                 Request::get(format!("/ngsi-ld/v1/entities?q={}", "a".repeat(9000)))
    2628            4 :                     .body(Body::empty())
    2629            4 :                     .expect("req"),
    2630            4 :             )
    2631            4 :             .await
    2632            4 :             .expect("resp");
    2633            4 :         assert_eq!(resp.status(), StatusCode::URI_TOO_LONG);
    2634              : 
    2635              :         // body over 4 MiB → bare 413
    2636            4 :         let resp = app
    2637            4 :             .clone()
    2638            4 :             .oneshot(
    2639            4 :                 Request::post("/ngsi-ld/v1/entities")
    2640            4 :                     .header("Content-Type", "application/json")
    2641            4 :                     .header(
    2642            4 :                         "Content-Length",
    2643            4 :                         ("x".repeat(*bounds::MAX_BODY_BYTES + 1)).len(),
    2644            4 :                     )
    2645            4 :                     .body(Body::from("x".repeat(*bounds::MAX_BODY_BYTES + 1)))
    2646            4 :                     .expect("req"),
    2647            4 :             )
    2648            4 :             .await
    2649            4 :             .expect("resp");
    2650            4 :         assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
    2651              : 
    2652              :         // limit above the ceiling → 403 TooManyResults
    2653            4 :         let resp = app
    2654            4 :             .clone()
    2655            4 :             .oneshot(
    2656            4 :                 Request::get("/ngsi-ld/v1/entities?type=T&limit=99999")
    2657            4 :                     .body(Body::empty())
    2658            4 :                     .expect("req"),
    2659            4 :             )
    2660            4 :             .await
    2661            4 :             .expect("resp");
    2662            4 :         assert_eq!(resp.status(), StatusCode::FORBIDDEN);
    2663            4 :         let doc = body_json(resp).await;
    2664            4 :         assert!(doc["type"]
    2665            4 :             .as_str()
    2666            4 :             .expect("type")
    2667            4 :             .ends_with("TooManyResults"));
    2668              : 
    2669              :         // batch above the item cap → 400
    2670            4 :         let big: Vec<serde_json::Value> = (0..*bounds::MAX_BATCH_ITEMS + 1)
    2671         4004 :             .map(|i| serde_json::json!({"id": format!("urn:b:{i}"), "type": "T"}))
    2672            4 :             .collect();
    2673            4 :         let resp = app
    2674            4 :             .clone()
    2675            4 :             .oneshot(
    2676            4 :                 Request::post("/ngsi-ld/v1/entityOperations/create")
    2677            4 :                     .header("Content-Type", "application/json")
    2678            4 :                     .header(
    2679            4 :                         "Content-Length",
    2680            4 :                         (serde_json::to_vec(&big).expect("json")).len(),
    2681            4 :                     )
    2682            4 :                     .body(Body::from(serde_json::to_vec(&big).expect("json")))
    2683            4 :                     .expect("req"),
    2684            4 :             )
    2685            4 :             .await
    2686            4 :             .expect("resp");
    2687            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    2688              : 
    2689              :         // joinLevel above the cap → 400
    2690            4 :         let resp = app
    2691            4 :             .clone()
    2692            4 :             .oneshot(
    2693            4 :                 Request::get("/ngsi-ld/v1/entities?type=T&join=inline&joinLevel=99")
    2694            4 :                     .body(Body::empty())
    2695            4 :                     .expect("req"),
    2696            4 :             )
    2697            4 :             .await
    2698            4 :             .expect("resp");
    2699            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    2700            4 :     }
    2701              : 
    2702              :     #[tokio::test]
    2703            4 :     async fn preadoptions_attr_get_value_options_head() {
    2704              :         // 2.0 pre-adoptions: #14 GET .../attrs/{attrId},
    2705              :         // #15 .../value, #58 HEAD, #59 OPTIONS with the route's Allow set.
    2706            4 :         let app = app();
    2707            4 :         let entity = serde_json::json!({
    2708            4 :             "id": "urn:ngsi-ld:Building:h3", "type": "Building",
    2709            4 :             "name": {"type": "Property", "value": "Hala"}
    2710              :         });
    2711            4 :         let resp = app
    2712            4 :             .clone()
    2713            4 :             .oneshot(
    2714            4 :                 Request::post("/ngsi-ld/v1/entities")
    2715            4 :                     .header("Content-Type", "application/json")
    2716            4 :                     .header("Content-Length", (entity.to_string()).len())
    2717            4 :                     .body(Body::from(entity.to_string()))
    2718            4 :                     .expect("req"),
    2719            4 :             )
    2720            4 :             .await
    2721            4 :             .expect("resp");
    2722            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2723              : 
    2724            4 :         let resp = app
    2725            4 :             .clone()
    2726            4 :             .oneshot(
    2727            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:h3/attrs/name")
    2728            4 :                     .body(Body::empty())
    2729            4 :                     .expect("req"),
    2730            4 :             )
    2731            4 :             .await
    2732            4 :             .expect("resp");
    2733            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2734            4 :         let doc = body_json(resp).await;
    2735            4 :         assert_eq!(doc["type"], "Property");
    2736            4 :         assert_eq!(doc["value"], "Hala");
    2737              : 
    2738            4 :         let resp = app
    2739            4 :             .clone()
    2740            4 :             .oneshot(
    2741            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:h3/attrs/name/value")
    2742            4 :                     .body(Body::empty())
    2743            4 :                     .expect("req"),
    2744            4 :             )
    2745            4 :             .await
    2746            4 :             .expect("resp");
    2747            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2748            4 :         assert_eq!(body_json(resp).await, serde_json::json!("Hala"));
    2749              : 
    2750              :         // absent attribute → 404 ResourceNotFound
    2751            4 :         let resp = app
    2752            4 :             .clone()
    2753            4 :             .oneshot(
    2754            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:h3/attrs/nope")
    2755            4 :                     .body(Body::empty())
    2756            4 :                     .expect("req"),
    2757            4 :             )
    2758            4 :             .await
    2759            4 :             .expect("resp");
    2760            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    2761              : 
    2762              :         // #59: OPTIONS answers 204 + Allow computed from the route
    2763            4 :         let resp = app
    2764            4 :             .clone()
    2765            4 :             .oneshot(
    2766            4 :                 Request::builder()
    2767            4 :                     .method("OPTIONS")
    2768            4 :                     .uri("/ngsi-ld/v1/entities")
    2769            4 :                     .body(Body::empty())
    2770            4 :                     .expect("req"),
    2771            4 :             )
    2772            4 :             .await
    2773            4 :             .expect("resp");
    2774            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    2775            4 :         let allow = resp
    2776            4 :             .headers()
    2777            4 :             .get("allow")
    2778            4 :             .expect("Allow")
    2779            4 :             .to_str()
    2780            4 :             .expect("str");
    2781            4 :         assert!(
    2782            4 :             allow.contains("GET") && allow.contains("POST"),
    2783              :             "Allow: {allow}"
    2784              :         );
    2785              : 
    2786              :         // #58: HEAD serves like GET, no body needed
    2787            4 :         let resp = app
    2788            4 :             .clone()
    2789            4 :             .oneshot(
    2790            4 :                 Request::builder()
    2791            4 :                     .method("HEAD")
    2792            4 :                     .uri("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:h3")
    2793            4 :                     .body(Body::empty())
    2794            4 :                     .expect("req"),
    2795            4 :             )
    2796            4 :             .await
    2797            4 :             .expect("resp");
    2798            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2799            4 :     }
    2800              : 
    2801              :     /// `information[].entities ×
    2802              :     /// (propertyNames + relationshipNames)` was expanded into an in-memory Vec
    2803              :     /// before any SQL ran, with no cardinality cap — a 4 MiB body produced on
    2804              :     /// the order of 10^10 objects and OOM-killed the process. Capped at the
    2805              :     /// validation boundary so it is a 400, not a dead pod: the request's
    2806              :     /// content is what is refused, and 5.9.2.4 raises BadRequestData for it.
    2807              :     #[tokio::test]
    2808            4 :     async fn registration_cardinality_is_capped_before_expansion() {
    2809            4 :         let app = app();
    2810            4 :         let entities: Vec<serde_json::Value> = (0..600)
    2811         2400 :             .map(|i| serde_json::json!({"id": format!("urn:e:{i}")}))
    2812            4 :             .collect();
    2813         2400 :         let props: Vec<String> = (0..600).map(|i| format!("p{i}")).collect();
    2814            4 :         let reg = serde_json::json!({
    2815            4 :             "type": "ContextSourceRegistration",
    2816            4 :             "endpoint": "http://peer.example/ngsi-ld/v1",
    2817            4 :             "information": [{"entities": entities, "propertyNames": props}]
    2818              :         });
    2819            4 :         let resp = app
    2820            4 :             .clone()
    2821            4 :             .oneshot(
    2822            4 :                 Request::post("/ngsi-ld/v1/csourceRegistrations")
    2823            4 :                     .header("Content-Type", "application/json")
    2824            4 :                     .header("Content-Length", (reg.to_string()).len())
    2825            4 :                     .body(Body::from(reg.to_string()))
    2826            4 :                     .expect("req"),
    2827            4 :             )
    2828            4 :             .await
    2829            4 :             .expect("resp");
    2830            4 :         assert_eq!(
    2831            4 :             resp.status(),
    2832              :             StatusCode::BAD_REQUEST,
    2833              :             "an oversized registration body is BadRequestData, not a query \
    2834              :              the client is told to narrow"
    2835              :         );
    2836              : 
    2837              :         // a registration of ordinary size is untouched by the cap
    2838            4 :         let reg = serde_json::json!({
    2839            4 :             "type": "ContextSourceRegistration",
    2840            4 :             "endpoint": "http://peer.example/ngsi-ld/v1",
    2841            4 :             "information": [{"entities": [{"type": "Vehicle"}],
    2842            4 :                              "propertyNames": ["speed", "heading"]}]
    2843              :         });
    2844            4 :         let resp = app
    2845            4 :             .clone()
    2846            4 :             .oneshot(
    2847            4 :                 Request::post("/ngsi-ld/v1/csourceRegistrations")
    2848            4 :                     .header("Content-Type", "application/json")
    2849            4 :                     .header("Content-Length", (reg.to_string()).len())
    2850            4 :                     .body(Body::from(reg.to_string()))
    2851            4 :                     .expect("req"),
    2852            4 :             )
    2853            4 :             .await
    2854            4 :             .expect("resp");
    2855            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2856            4 :     }
    2857              : 
    2858              :     #[tokio::test]
    2859            4 :     async fn tolerant_reader_echoes_unknown_members() {
    2860              :         // Unknown members of Subscription/Registration documents are
    2861              :         // stored and echoed, never rejected or stripped — a member added by a
    2862              :         // future spec version flows through a broker that predates it.
    2863            4 :         let app = app();
    2864            4 :         let sub = serde_json::json!({
    2865            4 :             "id": "urn:ngsi-ld:Subscription:tol1", "type": "Subscription",
    2866            4 :             "entities": [{"type": "Building"}],
    2867            4 :             "notification": {"endpoint": {"uri": "http://localhost:1/x"}},
    2868            4 :             "futureMember": {"nested": [1, 2, 3]}
    2869              :         });
    2870            4 :         let resp = app
    2871            4 :             .clone()
    2872            4 :             .oneshot(
    2873            4 :                 Request::post("/ngsi-ld/v1/subscriptions")
    2874            4 :                     .header("Content-Type", "application/json")
    2875            4 :                     .header("Content-Length", (sub.to_string()).len())
    2876            4 :                     .body(Body::from(sub.to_string()))
    2877            4 :                     .expect("req"),
    2878            4 :             )
    2879            4 :             .await
    2880            4 :             .expect("resp");
    2881            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2882            4 :         let resp = app
    2883            4 :             .clone()
    2884            4 :             .oneshot(
    2885            4 :                 Request::get("/ngsi-ld/v1/subscriptions/urn:ngsi-ld:Subscription:tol1")
    2886            4 :                     .body(Body::empty())
    2887            4 :                     .expect("req"),
    2888            4 :             )
    2889            4 :             .await
    2890            4 :             .expect("resp");
    2891            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2892            4 :         let doc = body_json(resp).await;
    2893            4 :         assert_eq!(
    2894            4 :             doc["futureMember"],
    2895            4 :             serde_json::json!({"nested": [1, 2, 3]})
    2896              :         );
    2897              : 
    2898            4 :         let reg = serde_json::json!({
    2899            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:tol1",
    2900            4 :             "type": "ContextSourceRegistration",
    2901            4 :             "information": [{"entities": [{"type": "Building"}]}],
    2902            4 :             "endpoint": "http://localhost:1/csr",
    2903            4 :             "futureMember": "kept"
    2904              :         });
    2905            4 :         let resp = app
    2906            4 :             .clone()
    2907            4 :             .oneshot(
    2908            4 :                 Request::post("/ngsi-ld/v1/csourceRegistrations")
    2909            4 :                     .header("Content-Type", "application/json")
    2910            4 :                     .header("Content-Length", (reg.to_string()).len())
    2911            4 :                     .body(Body::from(reg.to_string()))
    2912            4 :                     .expect("req"),
    2913            4 :             )
    2914            4 :             .await
    2915            4 :             .expect("resp");
    2916            4 :         assert_eq!(resp.status(), StatusCode::CREATED, "registration create");
    2917            4 :         let resp = app
    2918            4 :             .clone()
    2919            4 :             .oneshot(
    2920            4 :                 Request::get(
    2921            4 :                     "/ngsi-ld/v1/csourceRegistrations/urn:ngsi-ld:ContextSourceRegistration:tol1",
    2922            4 :                 )
    2923            4 :                 .body(Body::empty())
    2924            4 :                 .expect("req"),
    2925            4 :             )
    2926            4 :             .await
    2927            4 :             .expect("resp");
    2928            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2929            4 :         let doc = body_json(resp).await;
    2930            4 :         assert_eq!(doc["futureMember"], "kept");
    2931            4 :     }
    2932              : 
    2933              :     #[tokio::test]
    2934            4 :     async fn entity_create_retrieve_delete_roundtrip() {
    2935            4 :         let app = app();
    2936            4 :         let entity = serde_json::json!({
    2937            4 :             "id": "urn:ngsi-ld:Building:rt1",
    2938            4 :             "type": "Building",
    2939            4 :             "name": {"type": "Property", "value": "Eiffel Tower"}
    2940              :         });
    2941            4 :         let resp = app
    2942            4 :             .clone()
    2943            4 :             .oneshot(
    2944            4 :                 Request::post("/ngsi-ld/v1/entities")
    2945            4 :                     .header("Content-Type", "application/json")
    2946            4 :                     .header("Content-Length", (entity.to_string()).len())
    2947            4 :                     .body(Body::from(entity.to_string()))
    2948            4 :                     .expect("req"),
    2949            4 :             )
    2950            4 :             .await
    2951            4 :             .expect("resp");
    2952            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    2953            4 :         assert_eq!(
    2954            4 :             resp.headers().get("Location").map(|v| v.to_str().unwrap()),
    2955              :             Some("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:rt1")
    2956              :         );
    2957              : 
    2958              :         // duplicate → 409
    2959            4 :         let resp = app
    2960            4 :             .clone()
    2961            4 :             .oneshot(
    2962            4 :                 Request::post("/ngsi-ld/v1/entities")
    2963            4 :                     .header("Content-Type", "application/json")
    2964            4 :                     .header("Content-Length", (entity.to_string()).len())
    2965            4 :                     .body(Body::from(entity.to_string()))
    2966            4 :                     .expect("req"),
    2967            4 :             )
    2968            4 :             .await
    2969            4 :             .expect("resp");
    2970            4 :         assert_eq!(resp.status(), StatusCode::CONFLICT);
    2971              : 
    2972            4 :         let resp = app
    2973            4 :             .clone()
    2974            4 :             .oneshot(
    2975            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:rt1")
    2976            4 :                     .body(Body::empty())
    2977            4 :                     .expect("req"),
    2978            4 :             )
    2979            4 :             .await
    2980            4 :             .expect("resp");
    2981            4 :         assert_eq!(resp.status(), StatusCode::OK);
    2982            4 :         assert!(resp.headers().contains_key("Link"));
    2983            4 :         let body = body_json(resp).await;
    2984            4 :         assert_eq!(body["name"]["value"], "Eiffel Tower");
    2985            4 :         assert!(body.get("createdAt").is_none(), "sysAttrs off by default");
    2986              : 
    2987            4 :         let resp = app
    2988            4 :             .clone()
    2989            4 :             .oneshot(
    2990            4 :                 Request::delete("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:rt1")
    2991            4 :                     .body(Body::empty())
    2992            4 :                     .expect("req"),
    2993            4 :             )
    2994            4 :             .await
    2995            4 :             .expect("resp");
    2996            4 :         assert_eq!(resp.status(), StatusCode::NO_CONTENT);
    2997              : 
    2998            4 :         let resp = app
    2999            4 :             .oneshot(
    3000            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:rt1")
    3001            4 :                     .body(Body::empty())
    3002            4 :                     .expect("req"),
    3003            4 :             )
    3004            4 :             .await
    3005            4 :             .expect("resp");
    3006            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    3007            4 :     }
    3008              : 
    3009              :     #[tokio::test]
    3010            4 :     async fn query_requires_filter_and_unknown_param_is_400() {
    3011            4 :         let resp = app()
    3012            4 :             .oneshot(
    3013            4 :                 Request::get("/ngsi-ld/v1/entities")
    3014            4 :                     .body(Body::empty())
    3015            4 :                     .expect("req"),
    3016            4 :             )
    3017            4 :             .await
    3018            4 :             .expect("resp");
    3019            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    3020              : 
    3021            4 :         let resp = app()
    3022            4 :             .oneshot(
    3023            4 :                 Request::get("/ngsi-ld/v1/entities?invalidParams=x&type=Building")
    3024            4 :                     .body(Body::empty())
    3025            4 :                     .expect("req"),
    3026            4 :             )
    3027            4 :             .await
    3028            4 :             .expect("resp");
    3029            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    3030            4 :         let body = body_json(resp).await;
    3031            4 :         assert_eq!(
    3032            4 :             body["type"],
    3033            4 :             "https://uri.etsi.org/ngsi-ld/errors/InvalidRequest"
    3034            4 :         );
    3035            4 :     }
    3036              : 
    3037              :     #[tokio::test]
    3038            4 :     async fn unsupported_media_type_and_accept() {
    3039            4 :         let resp = app()
    3040            4 :             .oneshot(
    3041            4 :                 Request::post("/ngsi-ld/v1/entities")
    3042            4 :                     .header("Content-Type", "text/plain")
    3043            4 :                     .header("Content-Length", ("x").len())
    3044            4 :                     .body(Body::from("x"))
    3045            4 :                     .expect("req"),
    3046            4 :             )
    3047            4 :             .await
    3048            4 :             .expect("resp");
    3049            4 :         assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
    3050              : 
    3051            4 :         let resp = app()
    3052            4 :             .oneshot(
    3053            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building")
    3054            4 :                     .header("Accept", "text/csv")
    3055            4 :                     .body(Body::empty())
    3056            4 :                     .expect("req"),
    3057            4 :             )
    3058            4 :             .await
    3059            4 :             .expect("resp");
    3060            4 :         assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
    3061            4 :     }
    3062              : 
    3063              :     #[tokio::test]
    3064            4 :     async fn tenant_header_is_echoed_and_validated() {
    3065              :         // 5.5.10: an unknown tenant on a non-create op is NonexistentTenant
    3066              :         // 404 — and 6.3.14 still requires the tenant header on the response.
    3067            4 :         let resp = app()
    3068            4 :             .oneshot(
    3069            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building")
    3070            4 :                     .header("NGSILD-Tenant", "city-01")
    3071            4 :                     .body(Body::empty())
    3072            4 :                     .expect("req"),
    3073            4 :             )
    3074            4 :             .await
    3075            4 :             .expect("resp");
    3076            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    3077            4 :         assert_eq!(
    3078            4 :             resp.headers()
    3079            4 :                 .get("NGSILD-Tenant")
    3080            4 :                 .map(|v| v.to_str().expect("ascii")),
    3081              :             Some("city-01")
    3082              :         );
    3083              :         // once the tenant exists (implicit creation), the query echoes on 200
    3084            4 :         let app = app();
    3085            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:t1", "type": "Building"});
    3086            4 :         let body = entity.to_string();
    3087            4 :         let resp = app
    3088            4 :             .clone()
    3089            4 :             .oneshot(
    3090            4 :                 Request::post("/ngsi-ld/v1/entities")
    3091            4 :                     .header("Content-Type", "application/json")
    3092            4 :                     .header("Content-Length", body.len())
    3093            4 :                     .header("NGSILD-Tenant", "city-01")
    3094            4 :                     .body(Body::from(body))
    3095            4 :                     .expect("req"),
    3096            4 :             )
    3097            4 :             .await
    3098            4 :             .expect("resp");
    3099            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    3100            4 :         let resp = app
    3101            4 :             .oneshot(
    3102            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building")
    3103            4 :                     .header("NGSILD-Tenant", "city-01")
    3104            4 :                     .body(Body::empty())
    3105            4 :                     .expect("req"),
    3106            4 :             )
    3107            4 :             .await
    3108            4 :             .expect("resp");
    3109            4 :         assert_eq!(resp.status(), StatusCode::OK);
    3110            4 :         assert_eq!(
    3111            4 :             resp.headers()
    3112            4 :                 .get("NGSILD-Tenant")
    3113            4 :                 .map(|v| v.to_str().expect("ascii")),
    3114            4 :             Some("city-01")
    3115            4 :         );
    3116            4 :     }
    3117              : 
    3118              :     /// 5.5.10 / 6.3.14: the `snap-` tenant namespace is the broker's own —
    3119              :     /// the snapshot code mints `snap-index` (the synth-tenant reverse index)
    3120              :     /// and `snap-<uuid>` (one per snapshot). A client NGSILD-Tenant inside
    3121              :     /// that namespace would let the caller read and delete other tenants'
    3122              :     /// snapshot bookkeeping, so it is an invalid tenant value: 400
    3123              :     /// BadRequestData, and nothing reaches the store.
    3124              :     #[tokio::test]
    3125            4 :     async fn internal_tenant_namespace_is_reserved() {
    3126            4 :         let state = AppState::new("antares-test".into());
    3127            4 :         let app = router(state.clone());
    3128            4 :         let idx = antares_model::TenantId::new_internal("snap-index").expect("tenant");
    3129              : 
    3130              :         // create (implicit tenant creation) must not mint the internal tenant
    3131            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:si", "type": "Building"});
    3132            4 :         let body = entity.to_string();
    3133            4 :         let resp = app
    3134            4 :             .clone()
    3135            4 :             .oneshot(
    3136            4 :                 Request::post("/ngsi-ld/v1/entities")
    3137            4 :                     .header("Content-Type", "application/json")
    3138            4 :                     .header("Content-Length", body.len())
    3139            4 :                     .header("NGSILD-Tenant", "snap-index")
    3140            4 :                     .body(Body::from(body))
    3141            4 :                     .expect("req"),
    3142            4 :             )
    3143            4 :             .await
    3144            4 :             .expect("resp");
    3145            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    3146            4 :         assert_eq!(
    3147            4 :             body_json(resp).await["type"],
    3148              :             "https://uri.etsi.org/ngsi-ld/errors/BadRequestData"
    3149              :         );
    3150            4 :         assert!(
    3151            4 :             !state.store.tenant_exists(&idx).await.expect("store"),
    3152              :             "refused request must not create the internal tenant"
    3153              :         );
    3154            4 :         assert!(
    3155            4 :             state
    3156            4 :                 .store
    3157            4 :                 .list(&idx, antares_store::Kind::Entity)
    3158            4 :                 .await
    3159            4 :                 .expect("store")
    3160            4 :                 .is_empty(),
    3161              :             "refused request must not write into the internal tenant"
    3162              :         );
    3163              : 
    3164              :         // reads of the index are refused too, whatever the resource
    3165           12 :         for path in [
    3166            4 :             "/ngsi-ld/v1/entities?type=Building",
    3167            4 :             "/ngsi-ld/v1/subscriptions",
    3168            4 :             "/ngsi-ld/v1/snapshots",
    3169            4 :         ] {
    3170           12 :             let resp = app
    3171           12 :                 .clone()
    3172           12 :                 .oneshot(
    3173           12 :                     Request::get(path)
    3174           12 :                         .header("NGSILD-Tenant", "snap-index")
    3175           12 :                         .body(Body::empty())
    3176           12 :                         .expect("req"),
    3177           12 :                 )
    3178           12 :                 .await
    3179           12 :                 .expect("resp");
    3180           12 :             assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{path}");
    3181            4 :         }
    3182            4 :         // a per-snapshot synthetic tenant is equally off-limits, and so is the
    3183            4 :         // distributed-subscription inbound index, whose own record claims it
    3184            4 :         // is reserved while nothing enforced it
    3185            8 :         for tenant in ["snap-0123456789abcdef", "distsub-index"] {
    3186            8 :             let resp = app
    3187            8 :                 .clone()
    3188            8 :                 .oneshot(
    3189            8 :                     Request::get("/ngsi-ld/v1/entities?type=Building")
    3190            8 :                         .header("NGSILD-Tenant", tenant)
    3191            8 :                         .body(Body::empty())
    3192            8 :                         .expect("req"),
    3193            8 :                 )
    3194            8 :                 .await
    3195            8 :                 .expect("resp");
    3196            8 :             assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "{tenant}");
    3197            4 :         }
    3198            4 :     }
    3199              : 
    3200              :     /// The reserved namespace is the `snap-` prefix only: a tenant that
    3201              :     /// merely contains "snap" is an ordinary 5.5.10 tenant.
    3202              :     #[tokio::test]
    3203            4 :     async fn tenant_containing_snap_is_not_reserved() {
    3204            4 :         let app = app();
    3205            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:B:st", "type": "Building"});
    3206            4 :         let body = entity.to_string();
    3207            4 :         let resp = app
    3208            4 :             .clone()
    3209            4 :             .oneshot(
    3210            4 :                 Request::post("/ngsi-ld/v1/entities")
    3211            4 :                     .header("Content-Type", "application/json")
    3212            4 :                     .header("Content-Length", body.len())
    3213            4 :                     .header("NGSILD-Tenant", "snapshots-team")
    3214            4 :                     .body(Body::from(body))
    3215            4 :                     .expect("req"),
    3216            4 :             )
    3217            4 :             .await
    3218            4 :             .expect("resp");
    3219            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    3220            4 :         let resp = app
    3221            4 :             .oneshot(
    3222            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building")
    3223            4 :                     .header("NGSILD-Tenant", "snapshots-team")
    3224            4 :                     .body(Body::empty())
    3225            4 :                     .expect("req"),
    3226            4 :             )
    3227            4 :             .await
    3228            4 :             .expect("resp");
    3229            4 :         assert_eq!(resp.status(), StatusCode::OK);
    3230            4 :         assert_eq!(
    3231            4 :             resp.headers()
    3232            4 :                 .get("NGSILD-Tenant")
    3233            4 :                 .map(|v| v.to_str().expect("ascii")),
    3234            4 :             Some("snapshots-team")
    3235            4 :         );
    3236            4 :     }
    3237              : 
    3238              :     // ---- 5.6.21.4 Purge: the five qualifying conditions -------------------
    3239              : 
    3240           44 :     async fn create(app: &Router, id: &str, ty: &str) {
    3241           44 :         let entity = serde_json::json!({"id": id, "type": ty,
    3242           44 :             "name": {"type": "Property", "value": "x"}});
    3243           44 :         let resp = app
    3244           44 :             .clone()
    3245           44 :             .oneshot(
    3246           44 :                 Request::post("/ngsi-ld/v1/entities")
    3247           44 :                     .header("Content-Type", "application/json")
    3248           44 :                     .header("Content-Length", (entity.to_string()).len())
    3249           44 :                     .body(Body::from(entity.to_string()))
    3250           44 :                     .expect("req"),
    3251           44 :             )
    3252           44 :             .await
    3253           44 :             .expect("resp");
    3254           44 :         assert_eq!(resp.status(), StatusCode::CREATED);
    3255           44 :     }
    3256              : 
    3257           48 :     async fn purge(app: &Router, query: &str) -> StatusCode {
    3258           48 :         app.clone()
    3259           48 :             .oneshot(
    3260           48 :                 Request::delete(format!("/ngsi-ld/v1/entities?{query}"))
    3261           48 :                     .body(Body::empty())
    3262           48 :                     .expect("req"),
    3263           48 :             )
    3264           48 :             .await
    3265           48 :             .expect("resp")
    3266           48 :             .status()
    3267           48 :     }
    3268              : 
    3269              :     /// 4.3.6.1 on batch distribution: "all constraints specified in the
    3270              :     /// registration shall be respected" — a registration scoped to entity
    3271              :     /// type A must not swallow a batch item of type B. The B item is purely
    3272              :     /// local: it gets created; only the A item earns the Conflict part
    3273              :     /// (read-only registration), so the batch is a 207.
    3274              :     #[tokio::test]
    3275            4 :     async fn batch_items_outside_a_registrations_types_stay_local() {
    3276            4 :         crate::allow_private();
    3277            4 :         let app = app();
    3278            4 :         let reg = serde_json::json!({
    3279            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:type-scope",
    3280            4 :             "type": "ContextSourceRegistration",
    3281            4 :             "mode": "redirect",
    3282            4 :             "operations": ["retrieveEntity", "queryEntity"],
    3283            4 :             "information": [{"entities": [{"type": "ScopedType"}]}],
    3284            4 :             "endpoint": "http://127.0.0.1:1",
    3285              :         });
    3286            4 :         let resp = app
    3287            4 :             .clone()
    3288            4 :             .oneshot(
    3289            4 :                 Request::post("/ngsi-ld/v1/csourceRegistrations")
    3290            4 :                     .header("Content-Type", "application/json")
    3291            4 :                     .header("Content-Length", reg.to_string().len())
    3292            4 :                     .body(Body::from(reg.to_string()))
    3293            4 :                     .expect("req"),
    3294            4 :             )
    3295            4 :             .await
    3296            4 :             .expect("resp");
    3297            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    3298              : 
    3299            4 :         let batch = serde_json::json!([
    3300            4 :             {"id": "urn:ngsi-ld:ScopedType:remote", "type": "ScopedType",
    3301            4 :              "name": {"type": "Property", "value": "x"}},
    3302            4 :             {"id": "urn:ngsi-ld:Other:local", "type": "OtherType",
    3303            4 :              "name": {"type": "Property", "value": "y"}},
    3304              :         ]);
    3305            4 :         let resp = app
    3306            4 :             .clone()
    3307            4 :             .oneshot(
    3308            4 :                 Request::post("/ngsi-ld/v1/entityOperations/create")
    3309            4 :                     .header("Content-Type", "application/json")
    3310            4 :                     .header("Content-Length", batch.to_string().len())
    3311            4 :                     .body(Body::from(batch.to_string()))
    3312            4 :                     .expect("req"),
    3313            4 :             )
    3314            4 :             .await
    3315            4 :             .expect("resp");
    3316            4 :         assert_eq!(resp.status(), StatusCode::MULTI_STATUS, "one conflict part");
    3317              : 
    3318              :         // the out-of-scope item was created locally…
    3319            4 :         let resp = app
    3320            4 :             .clone()
    3321            4 :             .oneshot(
    3322            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Other:local?local=true")
    3323            4 :                     .body(Body::empty())
    3324            4 :                     .expect("req"),
    3325            4 :             )
    3326            4 :             .await
    3327            4 :             .expect("resp");
    3328            4 :         assert_eq!(resp.status(), StatusCode::OK, "non-matching item is local");
    3329              :         // …and the in-scope one was refused everywhere (redirect, read-only)
    3330            4 :         let resp = app
    3331            4 :             .clone()
    3332            4 :             .oneshot(
    3333            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:ScopedType:remote?local=true")
    3334            4 :                     .body(Body::empty())
    3335            4 :                     .expect("req"),
    3336            4 :             )
    3337            4 :             .await
    3338            4 :             .expect("resp");
    3339            4 :         assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    3340            4 :     }
    3341              : 
    3342              :     #[tokio::test]
    3343            4 :     async fn purge_rejects_id_and_idpattern_as_the_only_filter() {
    3344              :         // 5.6.21.4: id/idPattern are legal input data (5.6.21.3) but are not
    3345              :         // among the five qualifying conditions — "If none of the above is
    3346              :         // provided, then an error of type BadRequestData shall be raised (too
    3347              :         // wide query)". `idPattern=.*` alone used to delete the whole tenant.
    3348            4 :         let app = app();
    3349            4 :         create(&app, "urn:ngsi-ld:Building:purge1", "Building").await;
    3350              : 
    3351            4 :         assert_eq!(purge(&app, "idPattern=.%2A").await, StatusCode::BAD_REQUEST);
    3352            4 :         assert_eq!(
    3353            4 :             purge(&app, "id=urn:ngsi-ld:Building:purge1").await,
    3354              :             StatusCode::BAD_REQUEST
    3355              :         );
    3356              : 
    3357              :         // the entity is still there — the guard ran before any deletion
    3358            4 :         let resp = app
    3359            4 :             .clone()
    3360            4 :             .oneshot(
    3361            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:purge1")
    3362            4 :                     .body(Body::empty())
    3363            4 :                     .expect("req"),
    3364            4 :             )
    3365            4 :             .await
    3366            4 :             .expect("resp");
    3367            4 :         assert_eq!(resp.status(), StatusCode::OK);
    3368            4 :     }
    3369              : 
    3370              :     #[tokio::test]
    3371            4 :     async fn purge_requires_a_non_system_attribute_in_attrs_and_q() {
    3372              :         // 5.6.21.4 b) and c): the Attribute list / query must include "at
    3373              :         // least one non-system Attribute".
    3374            4 :         let app = app();
    3375            4 :         assert_eq!(
    3376            4 :             purge(&app, "attrs=createdAt").await,
    3377              :             StatusCode::BAD_REQUEST
    3378              :         );
    3379            4 :         assert_eq!(
    3380            4 :             purge(&app, "q=modifiedAt%3E%222020-01-01T00:00:00Z%22").await,
    3381              :             StatusCode::BAD_REQUEST
    3382              :         );
    3383              :         // a real attribute qualifies
    3384            4 :         assert_ne!(purge(&app, "attrs=name").await, StatusCode::BAD_REQUEST);
    3385            4 :         assert_ne!(
    3386            4 :             purge(&app, "q=name%3D%3D%22x%22").await,
    3387            4 :             StatusCode::BAD_REQUEST
    3388            4 :         );
    3389            4 :     }
    3390              : 
    3391              :     #[tokio::test]
    3392            4 :     async fn purge_accepts_each_qualifying_condition() {
    3393              :         // a) type, d) georel, e) local — none may 400 (5.6.21.4)
    3394            4 :         let app = app();
    3395            4 :         assert_ne!(purge(&app, "type=Building").await, StatusCode::BAD_REQUEST);
    3396            4 :         assert_ne!(purge(&app, "local=true").await, StatusCode::BAD_REQUEST);
    3397            4 :     }
    3398              : 
    3399              :     #[tokio::test]
    3400            4 :     async fn purge_rejects_linked_entity_paths() {
    3401              :         // 5.6.21.4: "If projection attributes are present and indicate the
    3402              :         // use of Linked Entity retrieval" and "If the filter conditions
    3403              :         // specified by the query includes Linked Entity attributes" →
    3404              :         // BadRequestData. `owner{name}` is the 4.9 LinkedEntityRelation form.
    3405            4 :         let app = app();
    3406            4 :         create(&app, "urn:ngsi-ld:Building:lep1", "Building").await;
    3407            4 :         assert_eq!(
    3408            4 :             purge(&app, "q=owner%7Bname%7D%3D%3D%22x%22").await,
    3409              :             StatusCode::BAD_REQUEST
    3410              :         );
    3411            4 :         assert_eq!(
    3412            4 :             purge(&app, "attrs=owner%7Bname%7D").await,
    3413              :             StatusCode::BAD_REQUEST
    3414              :         );
    3415              :         // the entity must survive the rejected purges
    3416            4 :         let resp = app
    3417            4 :             .clone()
    3418            4 :             .oneshot(
    3419            4 :                 Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:lep1")
    3420            4 :                     .body(Body::empty())
    3421            4 :                     .expect("req"),
    3422            4 :             )
    3423            4 :             .await
    3424            4 :             .expect("resp");
    3425            4 :         assert_eq!(resp.status(), StatusCode::OK);
    3426            4 :     }
    3427              : 
    3428              :     #[tokio::test]
    3429            4 :     async fn purge_validates_csf_syntax() {
    3430              :         // 5.6.21.4: "if the query, geoquery or context source filter are not
    3431              :         // syntactically valid ... an error of type BadRequestData shall be
    3432              :         // raised". csf is a 4.9 query over Context Source properties.
    3433            4 :         let app = app();
    3434            4 :         assert_eq!(
    3435            4 :             purge(&app, "type=Building&csf=%29%29bad%28%28").await,
    3436              :             StatusCode::BAD_REQUEST
    3437              :         );
    3438              :         // a well-formed csf is accepted
    3439            4 :         assert_ne!(
    3440            4 :             purge(&app, "type=Building&csf=endpoint%3D%3D%22x%22").await,
    3441            4 :             StatusCode::BAD_REQUEST
    3442            4 :         );
    3443            4 :     }
    3444              : 
    3445              :     // ---- 5.7.1.4 Retrieve Entity ------------------------------------------
    3446              : 
    3447          116 :     async fn get_status(app: &Router, uri: &str, accept: Option<&str>) -> StatusCode {
    3448          116 :         let mut req = Request::get(uri);
    3449          116 :         if let Some(a) = accept {
    3450            8 :             req = req.header("Accept", a);
    3451          108 :         }
    3452          116 :         app.clone()
    3453          116 :             .oneshot(req.body(Body::empty()).expect("req"))
    3454          116 :             .await
    3455          116 :             .expect("resp")
    3456          116 :             .status()
    3457          116 :     }
    3458              : 
    3459              :     #[tokio::test]
    3460            4 :     async fn retrieve_honors_the_type_selector() {
    3461              :         // 5.7.1.4: ResourceNotFound when no entity "whose id (URI), and
    3462              :         // where specified type, is equivalent" exists — ?type narrows the
    3463              :         // retrieve target (4.17); a matching or wildcard selector passes.
    3464            4 :         let app = app();
    3465            4 :         create(&app, "urn:ngsi-ld:Building:rt1", "Building").await;
    3466            4 :         let uri = "/ngsi-ld/v1/entities/urn:ngsi-ld:Building:rt1";
    3467            4 :         assert_eq!(
    3468            4 :             get_status(&app, &format!("{uri}?type=Vehicle"), None).await,
    3469              :             StatusCode::NOT_FOUND
    3470              :         );
    3471            4 :         assert_eq!(
    3472            4 :             get_status(&app, &format!("{uri}?type=Building"), None).await,
    3473              :             StatusCode::OK
    3474              :         );
    3475            4 :         assert_eq!(
    3476            4 :             get_status(&app, &format!("{uri}?type=%2A"), None).await,
    3477            4 :             StatusCode::OK
    3478            4 :         );
    3479            4 :     }
    3480              : 
    3481              :     #[tokio::test]
    3482            4 :     async fn retrieve_geometry_property_needs_geojson_accept() {
    3483              :         // 5.7.1.4: "If geometryProperty parameter is present and the Accept
    3484              :         // Header is not set to application/geo+json ... BadRequestData".
    3485            4 :         let app = app();
    3486            4 :         create(&app, "urn:ngsi-ld:Building:rg1", "Building").await;
    3487            4 :         let uri = "/ngsi-ld/v1/entities/urn:ngsi-ld:Building:rg1?geometryProperty=location";
    3488            4 :         assert_eq!(get_status(&app, uri, None).await, StatusCode::BAD_REQUEST);
    3489            4 :         assert_eq!(
    3490            4 :             get_status(&app, uri, Some("application/json")).await,
    3491              :             StatusCode::BAD_REQUEST
    3492              :         );
    3493            4 :         assert_eq!(
    3494            4 :             get_status(&app, uri, Some("application/geo+json")).await,
    3495            4 :             StatusCode::OK
    3496            4 :         );
    3497            4 :     }
    3498              : 
    3499              :     #[tokio::test]
    3500            4 :     async fn retrieve_linked_projection_requires_join_and_depth() {
    3501              :         // 5.7.1.4: projection attributes that "indicate the use of Linked
    3502              :         // Entity retrieval" without join, or that project deeper than
    3503              :         // joinLevel, are BadRequestData.
    3504            4 :         let app = app();
    3505            4 :         create(&app, "urn:ngsi-ld:Building:rl1", "Building").await;
    3506            4 :         let uri = "/ngsi-ld/v1/entities/urn:ngsi-ld:Building:rl1";
    3507              :         // {…} selection without join
    3508            4 :         assert_eq!(
    3509            4 :             get_status(&app, &format!("{uri}?pick=owner%7Bname%7D"), None).await,
    3510              :             StatusCode::BAD_REQUEST
    3511              :         );
    3512              :         // join=@none is "Linked Entity retrieval not specified"
    3513            4 :         assert_eq!(
    3514            4 :             get_status(
    3515            4 :                 &app,
    3516            4 :                 &format!("{uri}?pick=owner%7Bname%7D&join=%40none"),
    3517            4 :                 None
    3518            4 :             )
    3519            4 :             .await,
    3520              :             StatusCode::BAD_REQUEST
    3521              :         );
    3522              :         // depth 2 projection over joinLevel=1
    3523            4 :         assert_eq!(
    3524            4 :             get_status(
    3525            4 :                 &app,
    3526            4 :                 &format!("{uri}?pick=owner%7Bworks%7Bname%7D%7D&join=inline&joinLevel=1"),
    3527            4 :                 None
    3528            4 :             )
    3529            4 :             .await,
    3530              :             StatusCode::BAD_REQUEST
    3531              :         );
    3532              :         // well-formed: depth 1 within joinLevel=1, plain member kept
    3533            4 :         assert_eq!(
    3534            4 :             get_status(
    3535            4 :                 &app,
    3536            4 :                 &format!("{uri}?pick=id,type,name,owner%7Bname%7D&join=inline&joinLevel=1"),
    3537            4 :                 None
    3538            4 :             )
    3539            4 :             .await,
    3540            4 :             StatusCode::OK
    3541            4 :         );
    3542            4 :     }
    3543              : 
    3544              :     // ---- 5.7.2.4 Query Entities validation --------------------------------
    3545              : 
    3546              :     #[tokio::test]
    3547            4 :     async fn query_requires_a_non_system_attribute_in_attrs_and_q() {
    3548              :         // 5.7.2.4 b/c: the Attribute list / query only qualifies the
    3549              :         // too-wide guard when it includes "at least one non-system
    3550              :         // Attribute" — system names alone are still a too-wide 400.
    3551            4 :         let app = app();
    3552            4 :         assert_eq!(
    3553            4 :             get_status(&app, "/ngsi-ld/v1/entities?attrs=createdAt", None).await,
    3554              :             StatusCode::BAD_REQUEST
    3555              :         );
    3556            4 :         assert_eq!(
    3557            4 :             get_status(
    3558            4 :                 &app,
    3559            4 :                 "/ngsi-ld/v1/entities?q=modifiedAt%3E%222020-01-01T00:00:00Z%22",
    3560            4 :                 None
    3561            4 :             )
    3562            4 :             .await,
    3563              :             StatusCode::BAD_REQUEST
    3564              :         );
    3565            4 :         assert_ne!(
    3566            4 :             get_status(&app, "/ngsi-ld/v1/entities?attrs=name", None).await,
    3567            4 :             StatusCode::BAD_REQUEST
    3568            4 :         );
    3569            4 :     }
    3570              : 
    3571              :     #[tokio::test]
    3572            4 :     async fn query_linked_entity_guards() {
    3573              :         // 5.7.2.4: projection or filter conditions that use Linked Entity
    3574              :         // paths require join, and their depth may not exceed joinLevel
    3575              :         // ("too deep query"); csf must be syntactically valid.
    3576            4 :         let app = app();
    3577            4 :         create(&app, "urn:ngsi-ld:Building:ql1", "Building").await;
    3578            4 :         let base = "/ngsi-ld/v1/entities?type=Building";
    3579              :         // {…} projection without join
    3580            4 :         assert_eq!(
    3581            4 :             get_status(&app, &format!("{base}&pick=owner%7Bname%7D"), None).await,
    3582              :             StatusCode::BAD_REQUEST
    3583              :         );
    3584              :         // linked q term without join
    3585            4 :         assert_eq!(
    3586            4 :             get_status(
    3587            4 :                 &app,
    3588            4 :                 &format!("{base}&q=owner%7Bname%7D%3D%3D%22x%22"),
    3589            4 :                 None
    3590            4 :             )
    3591            4 :             .await,
    3592              :             StatusCode::BAD_REQUEST
    3593              :         );
    3594              :         // linked q term two hops deep over joinLevel=1
    3595            4 :         assert_eq!(
    3596            4 :             get_status(
    3597            4 :                 &app,
    3598            4 :                 &format!(
    3599            4 :                     "{base}&q=owner%7Bworks%7Bname%7D%7D%3D%3D%22x%22&join=inline&joinLevel=1"
    3600            4 :                 ),
    3601            4 :                 None
    3602            4 :             )
    3603            4 :             .await,
    3604              :             StatusCode::BAD_REQUEST
    3605              :         );
    3606              :         // invalid csf
    3607            4 :         assert_eq!(
    3608            4 :             get_status(&app, &format!("{base}&csf=%29%29bad%28%28"), None).await,
    3609              :             StatusCode::BAD_REQUEST
    3610              :         );
    3611              :         // well-formed linked q within depth is accepted
    3612            4 :         assert_eq!(
    3613            4 :             get_status(
    3614            4 :                 &app,
    3615            4 :                 &format!("{base}&q=owner%7Bname%7D%3D%3D%22x%22&join=inline&joinLevel=1"),
    3616            4 :                 None
    3617            4 :             )
    3618            4 :             .await,
    3619            4 :             StatusCode::OK
    3620            4 :         );
    3621            4 :     }
    3622              : 
    3623              :     #[tokio::test]
    3624            4 :     async fn temporal_retrieve_rejects_linked_projection() {
    3625              :         // 5.7.3.4: "If projection attributes are present and indicate the
    3626              :         // use of Linked Entity retrieval, an error of type BadRequestData
    3627              :         // shall be raised" — unconditional, temporal has no join.
    3628            4 :         let app = app();
    3629            4 :         assert_eq!(
    3630            4 :             get_status(
    3631            4 :                 &app,
    3632            4 :                 "/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:Building:tl1?pick=owner%7Bname%7D",
    3633            4 :                 None
    3634            4 :             )
    3635            4 :             .await,
    3636            4 :             StatusCode::BAD_REQUEST
    3637            4 :         );
    3638            4 :     }
    3639              : 
    3640              :     #[tokio::test]
    3641            4 :     async fn temporal_query_validation_edges() {
    3642              :         // 5.7.4.4: non-system rule for attrs/q; Linked Entity projection or
    3643              :         // filter is an unconditional 400; invalid id URI 400; csf syntax
    3644              :         // 400; orderBy may only name "id" on the temporal query.
    3645            4 :         let app = app();
    3646            4 :         let w = "timerel=after&timeAt=2020-01-01T00:00:00Z";
    3647            4 :         let cases: &[(&str, StatusCode, &str)] = &[
    3648            4 :             (
    3649            4 :                 "attrs=createdAt",
    3650            4 :                 StatusCode::BAD_REQUEST,
    3651            4 :                 "system attrs alone are too wide",
    3652            4 :             ),
    3653            4 :             (
    3654            4 :                 "type=Building&pick=owner%7Bname%7D",
    3655            4 :                 StatusCode::BAD_REQUEST,
    3656            4 :                 "linked projection",
    3657            4 :             ),
    3658            4 :             (
    3659            4 :                 "type=Building&q=owner%7Bname%7D%3D%3D%22x%22",
    3660            4 :                 StatusCode::BAD_REQUEST,
    3661            4 :                 "linked filter",
    3662            4 :             ),
    3663            4 :             (
    3664            4 :                 "type=Building&id=not%20a%20uri",
    3665            4 :                 StatusCode::BAD_REQUEST,
    3666            4 :                 "invalid id URI",
    3667            4 :             ),
    3668            4 :             (
    3669            4 :                 "type=Building&csf=%29%29bad%28%28",
    3670            4 :                 StatusCode::BAD_REQUEST,
    3671            4 :                 "invalid csf",
    3672            4 :             ),
    3673            4 :             (
    3674            4 :                 "type=Building&orderBy=name",
    3675            4 :                 StatusCode::BAD_REQUEST,
    3676            4 :                 "orderBy other than id",
    3677            4 :             ),
    3678            4 :             (
    3679            4 :                 "type=Building&orderBy=id",
    3680            4 :                 StatusCode::OK,
    3681            4 :                 "orderBy id is legal",
    3682            4 :             ),
    3683            4 :         ];
    3684           28 :         for (qs, want, why) in cases {
    3685           28 :             let uri = format!("/ngsi-ld/v1/temporal/entities?{w}&{qs}");
    3686           28 :             assert_eq!(get_status(&app, &uri, None).await, *want, "{why}");
    3687            4 :         }
    3688            4 :     }
    3689              : 
    3690              :     #[tokio::test]
    3691            4 :     async fn temporal_query_values_filter_respects_the_window() {
    3692              :         // 5.7.4.4: "the values filter query shall be checked against all
    3693              :         // the Attribute instances resulting from the initial filtering
    3694              :         // performed by the temporal query" — an instance OUTSIDE the
    3695              :         // interval must not satisfy q.
    3696            4 :         let app = app();
    3697            4 :         let body = serde_json::json!({
    3698            4 :             "id": "urn:ngsi-ld:Vehicle:tw1", "type": "Vehicle",
    3699            4 :             "speed": [
    3700            4 :                 {"type": "Property", "value": 5,
    3701            4 :                  "observedAt": "2026-01-01T00:00:00Z"},
    3702            4 :                 {"type": "Property", "value": 99,
    3703            4 :                  "observedAt": "2026-03-01T00:00:00Z"}
    3704              :             ]
    3705              :         })
    3706            4 :         .to_string();
    3707            4 :         let resp = app
    3708            4 :             .clone()
    3709            4 :             .oneshot(
    3710            4 :                 Request::post("/ngsi-ld/v1/temporal/entities")
    3711            4 :                     .header("Content-Type", "application/json")
    3712            4 :                     .header("Content-Length", body.len())
    3713            4 :                     .body(Body::from(body))
    3714            4 :                     .expect("req"),
    3715            4 :             )
    3716            4 :             .await
    3717            4 :             .expect("resp");
    3718            4 :         assert!(resp.status().is_success(), "seed: {}", resp.status());
    3719              :         // window covers ONLY the value-5 instance; q asks for 99
    3720            4 :         let uri = "/ngsi-ld/v1/temporal/entities?type=Vehicle&timerel=between&timeAt=2025-12-01T00:00:00Z&endTimeAt=2026-02-01T00:00:00Z&q=speed%3D%3D99";
    3721            4 :         let resp = app
    3722            4 :             .clone()
    3723            4 :             .oneshot(Request::get(uri).body(Body::empty()).expect("req"))
    3724            4 :             .await
    3725            4 :             .expect("resp");
    3726            4 :         let status = resp.status();
    3727            4 :         let docs = body_json(resp).await;
    3728            4 :         assert!(status.is_success(), "query: {status}");
    3729            4 :         assert_eq!(
    3730            4 :             docs.as_array().map(Vec::len),
    3731            4 :             Some(0),
    3732            4 :             "out-of-window instance must not satisfy q: {docs}"
    3733            4 :         );
    3734            4 :     }
    3735              : 
    3736              :     // ---- 5.9.2.4 Create Context Source Registration -----------------------
    3737              : 
    3738           40 :     async fn post_json(app: &Router, uri: &str, body: serde_json::Value) -> StatusCode {
    3739           40 :         let body = body.to_string();
    3740           40 :         app.clone()
    3741           40 :             .oneshot(
    3742           40 :                 Request::post(uri)
    3743           40 :                     .header("Content-Type", "application/json")
    3744           40 :                     .header("Content-Length", body.len())
    3745           40 :                     .body(Body::from(body))
    3746           40 :                     .expect("req"),
    3747           40 :             )
    3748           40 :             .await
    3749           40 :             .expect("resp")
    3750           40 :             .status()
    3751           40 :     }
    3752              : 
    3753              :     #[tokio::test]
    3754            4 :     async fn csr_create_mode_restrictions() {
    3755              :         // 5.9.2.4: auxiliary registrations may only offer retrieveOps /
    3756              :         // retrieveEntity / queryEntity (or a combination); an exclusive
    3757              :         // registration conflicts with an existing entity carrying any of
    3758              :         // its Attributes; a redirect registration conflicts with any
    3759              :         // existing matching entity.
    3760            4 :         let app = app();
    3761           24 :         let reg = |id: &str, mode: &str, ops: serde_json::Value, ent: serde_json::Value| {
    3762           24 :             serde_json::json!({
    3763           24 :                 "id": format!("urn:ngsi-ld:ContextSourceRegistration:{id}"),
    3764           24 :                 "type": "ContextSourceRegistration",
    3765           24 :                 "mode": mode,
    3766           24 :                 "operations": ops,
    3767           24 :                 "information": [ent],
    3768           24 :                 "endpoint": "http://source.example.com"
    3769              :             })
    3770           24 :         };
    3771            4 :         let uri = "/ngsi-ld/v1/csourceRegistrations";
    3772              : 
    3773              :         // auxiliary with a write op → 400; retrieve/query combination → 201
    3774            4 :         assert_eq!(
    3775            4 :             post_json(
    3776            4 :                 &app,
    3777            4 :                 uri,
    3778            4 :                 reg(
    3779            4 :                     "aux-bad",
    3780            4 :                     "auxiliary",
    3781            4 :                     serde_json::json!(["updateEntity"]),
    3782            4 :                     serde_json::json!({"entities": [{"type": "Building"}]})
    3783            4 :                 )
    3784            4 :             )
    3785            4 :             .await,
    3786              :             StatusCode::BAD_REQUEST
    3787              :         );
    3788            4 :         assert_eq!(
    3789            4 :             post_json(
    3790            4 :                 &app,
    3791            4 :                 uri,
    3792            4 :                 reg(
    3793            4 :                     "aux-ok",
    3794            4 :                     "auxiliary",
    3795            4 :                     serde_json::json!(["retrieveOps", "queryEntity"]),
    3796            4 :                     serde_json::json!({"entities": [{"type": "Building"}]})
    3797            4 :                 )
    3798            4 :             )
    3799            4 :             .await,
    3800              :             StatusCode::CREATED
    3801              :         );
    3802              : 
    3803              :         // exclusive vs existing entity carrying the registered attribute
    3804            4 :         create(&app, "urn:ngsi-ld:Building:csr1", "Building").await; // has "name"
    3805            4 :         assert_eq!(
    3806            4 :             post_json(
    3807            4 :                 &app,
    3808            4 :                 uri,
    3809            4 :                 reg(
    3810            4 :                     "exc-conflict",
    3811            4 :                     "exclusive",
    3812            4 :                     serde_json::json!(["retrieveOps"]),
    3813            4 :                     serde_json::json!({
    3814            4 :                         "entities": [{"type": "Building", "id": "urn:ngsi-ld:Building:csr1"}],
    3815            4 :                         "propertyNames": ["name"]
    3816            4 :                     })
    3817            4 :                 )
    3818            4 :             )
    3819            4 :             .await,
    3820              :             StatusCode::CONFLICT
    3821              :         );
    3822              :         // exclusive over an attr the entity does NOT have is fine
    3823            4 :         assert_eq!(
    3824            4 :             post_json(
    3825            4 :                 &app,
    3826            4 :                 uri,
    3827            4 :                 reg(
    3828            4 :                     "exc-ok",
    3829            4 :                     "exclusive",
    3830            4 :                     serde_json::json!(["retrieveOps"]),
    3831            4 :                     serde_json::json!({
    3832            4 :                         "entities": [{"type": "Building", "id": "urn:ngsi-ld:Building:csr1"}],
    3833            4 :                         "propertyNames": ["capacity"]
    3834            4 :                     })
    3835            4 :                 )
    3836            4 :             )
    3837            4 :             .await,
    3838              :             StatusCode::CREATED
    3839              :         );
    3840              : 
    3841              :         // redirect vs any existing matching entity
    3842            4 :         assert_eq!(
    3843            4 :             post_json(
    3844            4 :                 &app,
    3845            4 :                 uri,
    3846            4 :                 reg(
    3847            4 :                     "red-conflict",
    3848            4 :                     "redirect",
    3849            4 :                     serde_json::json!(["retrieveOps"]),
    3850            4 :                     serde_json::json!({
    3851            4 :                         "entities": [{"type": "Building", "id": "urn:ngsi-ld:Building:csr1"}]
    3852            4 :                     })
    3853            4 :                 )
    3854            4 :             )
    3855            4 :             .await,
    3856              :             StatusCode::CONFLICT
    3857              :         );
    3858              :         // redirect for an untouched id is fine
    3859            4 :         assert_eq!(
    3860            4 :             post_json(
    3861            4 :                 &app,
    3862            4 :                 uri,
    3863            4 :                 reg(
    3864            4 :                     "red-ok",
    3865            4 :                     "redirect",
    3866            4 :                     serde_json::json!(["retrieveOps"]),
    3867            4 :                     serde_json::json!({
    3868            4 :                         "entities": [{"type": "Building", "id": "urn:ngsi-ld:Building:other"}]
    3869            4 :                     })
    3870            4 :                 )
    3871            4 :             )
    3872            4 :             .await,
    3873            4 :             StatusCode::CREATED
    3874            4 :         );
    3875            4 :     }
    3876              : 
    3877              :     #[tokio::test]
    3878            4 :     async fn csr_expires_at_deletes_the_registration() {
    3879              :         // 5.9.2.4: "If expiresAt is a date and time in the future,
    3880              :         // implementations shall delete the Registration when this point in
    3881              :         // time is reached" — after expiry the registration is gone from
    3882              :         // retrieve and query.
    3883            4 :         let app = app();
    3884            4 :         let soon = (chrono::Utc::now() + chrono::Duration::milliseconds(1100))
    3885            4 :             .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
    3886            4 :         let reg = serde_json::json!({
    3887            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:expiring",
    3888            4 :             "type": "ContextSourceRegistration",
    3889            4 :             "expiresAt": soon,
    3890            4 :             "information": [{"entities": [{"type": "Building"}]}],
    3891            4 :             "endpoint": "http://source.example.com"
    3892              :         });
    3893            4 :         assert_eq!(
    3894            4 :             post_json(&app, "/ngsi-ld/v1/csourceRegistrations", reg).await,
    3895              :             StatusCode::CREATED
    3896              :         );
    3897            4 :         let uri = "/ngsi-ld/v1/csourceRegistrations/urn:ngsi-ld:ContextSourceRegistration:expiring";
    3898            4 :         assert_eq!(get_status(&app, uri, None).await, StatusCode::OK);
    3899            4 :         tokio::time::sleep(std::time::Duration::from_millis(1400)).await;
    3900            4 :         assert_eq!(
    3901            4 :             get_status(&app, uri, None).await,
    3902              :             StatusCode::NOT_FOUND,
    3903              :             "expired registration must be gone"
    3904              :         );
    3905            4 :         let resp = app
    3906            4 :             .clone()
    3907            4 :             .oneshot(
    3908            4 :                 Request::get("/ngsi-ld/v1/csourceRegistrations?type=Building")
    3909            4 :                     .body(Body::empty())
    3910            4 :                     .expect("req"),
    3911            4 :             )
    3912            4 :             .await
    3913            4 :             .expect("resp");
    3914            4 :         let docs = body_json(resp).await;
    3915            4 :         assert!(
    3916            4 :             !docs.to_string().contains("expiring"),
    3917            4 :             "expired registration must not be listed: {docs}"
    3918            4 :         );
    3919            4 :     }
    3920              : 
    3921              :     /// 5.9.2.4 with RFC 9110 §9.2.1: the reads that hide an expired
    3922              :     /// registration write nothing, and `sweep_expired_docs` is what frees
    3923              :     /// the row — for every kind that carries its own expiry, over every
    3924              :     /// tenant the driver will name.
    3925              :     #[tokio::test]
    3926            4 :     async fn expired_documents_are_freed_by_the_sweep_and_by_no_read() {
    3927              :         use antares_store::Kind;
    3928            4 :         let st = AppState::new("antares-sweep".into());
    3929            4 :         let app = router(st.clone());
    3930            4 :         let t = antares_model::TenantId::default();
    3931            4 :         let id = "urn:ngsi-ld:ContextSourceRegistration:swept";
    3932            4 :         st.store
    3933            4 :             .create(
    3934            4 :                 &t,
    3935            4 :                 Kind::Registration,
    3936            4 :                 id,
    3937            4 :                 serde_json::json!({
    3938            4 :                     "id": id,
    3939            4 :                     "type": "ContextSourceRegistration",
    3940            4 :                     "expiresAt": "2000-01-01T00:00:00.000Z",
    3941            4 :                     "information": [{"entities": [{"type": "Building"}]}],
    3942            4 :                     "endpoint": "http://source.example.com",
    3943            4 :                 }),
    3944            4 :             )
    3945            4 :             .await
    3946            4 :             .expect("seed");
    3947              : 
    3948            4 :         let uri = format!("/ngsi-ld/v1/csourceRegistrations/{id}");
    3949            4 :         assert_eq!(
    3950            4 :             get_status(&app, &uri, None).await,
    3951              :             StatusCode::NOT_FOUND,
    3952              :             "5.9.2.4: an expired registration is gone to a reader"
    3953              :         );
    3954            4 :         assert!(
    3955            4 :             st.store
    3956            4 :                 .get(&t, Kind::Registration, id)
    3957            4 :                 .await
    3958            4 :                 .expect("store")
    3959            4 :                 .is_some(),
    3960              :             "the read deleted the row: a GET must be safe (RFC 9110 9.2.1)"
    3961              :         );
    3962              : 
    3963            4 :         assert_eq!(sweep_expired_docs(&st).await, 1, "the sweep reaps it");
    3964            4 :         assert!(
    3965            4 :             st.store
    3966            4 :                 .get(&t, Kind::Registration, id)
    3967            4 :                 .await
    3968            4 :                 .expect("store")
    3969            4 :                 .is_none(),
    3970              :             "the sweep left the row behind"
    3971              :         );
    3972            4 :         assert_eq!(sweep_expired_docs(&st).await, 0, "the sweep is idempotent");
    3973            4 :     }
    3974              : 
    3975              :     #[tokio::test]
    3976            4 :     async fn csr_update_reapplies_mode_restrictions() {
    3977              :         // 5.9.3.4: the exclusive/redirect entity-conflict rules and the
    3978              :         // auxiliary ops restriction apply to the post-merge document.
    3979            4 :         let app = app();
    3980            4 :         create(&app, "urn:ngsi-ld:Building:csru1", "Building").await;
    3981            4 :         let reg = serde_json::json!({
    3982            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:upd1",
    3983            4 :             "type": "ContextSourceRegistration",
    3984            4 :             "mode": "inclusive",
    3985            4 :             "information": [{"entities": [{"type": "Building", "id": "urn:ngsi-ld:Building:csru1"}]}],
    3986            4 :             "endpoint": "http://source.example.com"
    3987              :         });
    3988            4 :         assert_eq!(
    3989            4 :             post_json(&app, "/ngsi-ld/v1/csourceRegistrations", reg).await,
    3990              :             StatusCode::CREATED
    3991              :         );
    3992              :         // flipping the mode to redirect now collides with the entity
    3993            4 :         let patch = serde_json::json!({"mode": "redirect"}).to_string();
    3994            4 :         let resp = app
    3995            4 :             .clone()
    3996            4 :             .oneshot(
    3997            4 :                 Request::patch(
    3998            4 :                     "/ngsi-ld/v1/csourceRegistrations/urn:ngsi-ld:ContextSourceRegistration:upd1",
    3999            4 :                 )
    4000            4 :                 .header("Content-Type", "application/json")
    4001            4 :                 .header("Content-Length", patch.len())
    4002            4 :                 .body(Body::from(patch))
    4003            4 :                 .expect("req"),
    4004            4 :             )
    4005            4 :             .await
    4006            4 :             .expect("resp");
    4007            4 :         assert_eq!(resp.status(), StatusCode::CONFLICT);
    4008              :         // flipping to auxiliary while operations carry writes is 400
    4009            4 :         let patch =
    4010            4 :             serde_json::json!({"mode": "auxiliary", "operations": ["updateEntity"]}).to_string();
    4011            4 :         let resp = app
    4012            4 :             .clone()
    4013            4 :             .oneshot(
    4014            4 :                 Request::patch(
    4015            4 :                     "/ngsi-ld/v1/csourceRegistrations/urn:ngsi-ld:ContextSourceRegistration:upd1",
    4016            4 :                 )
    4017            4 :                 .header("Content-Type", "application/json")
    4018            4 :                 .header("Content-Length", patch.len())
    4019            4 :                 .body(Body::from(patch))
    4020            4 :                 .expect("req"),
    4021            4 :             )
    4022            4 :             .await
    4023            4 :             .expect("resp");
    4024            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    4025            4 :     }
    4026              : 
    4027              :     #[tokio::test]
    4028            4 :     async fn csr_query_csf_scope_and_geo_filters() {
    4029              :         // 5.10.2.4: the context source filter (csf) matches the
    4030              :         // registration's own Context Source Properties; the Scope query
    4031              :         // matches its scope; the geoquery matches its location.
    4032            4 :         let app = app();
    4033            4 :         let reg_a = serde_json::json!({
    4034            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:disc-a",
    4035            4 :             "type": "ContextSourceRegistration",
    4036            4 :             "information": [{"entities": [{"type": "Building"}]}],
    4037            4 :             "endpoint": "http://a.example.com",
    4038            4 :             "scope": "/Madrid/Centro",
    4039            4 :             "location": {"type": "Point", "coordinates": [8.68, 49.41]}
    4040              :         });
    4041            4 :         let reg_b = serde_json::json!({
    4042            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:disc-b",
    4043            4 :             "type": "ContextSourceRegistration",
    4044            4 :             "information": [{"entities": [{"type": "Building"}]}],
    4045            4 :             "endpoint": "http://b.example.com",
    4046            4 :             "scope": "/Berlin"
    4047              :         });
    4048            8 :         for r in [reg_a, reg_b] {
    4049            8 :             assert_eq!(
    4050            8 :                 post_json(&app, "/ngsi-ld/v1/csourceRegistrations", r).await,
    4051              :                 StatusCode::CREATED
    4052              :             );
    4053              :         }
    4054           12 :         let list = |q: String| {
    4055           12 :             let app = app.clone();
    4056           12 :             async move {
    4057           12 :                 let resp = app
    4058           12 :                     .oneshot(
    4059           12 :                         Request::get(format!(
    4060           12 :                             "/ngsi-ld/v1/csourceRegistrations?type=Building&{q}"
    4061           12 :                         ))
    4062           12 :                         .body(Body::empty())
    4063           12 :                         .expect("req"),
    4064           12 :                     )
    4065           12 :                     .await
    4066           12 :                     .expect("resp");
    4067           12 :                 assert_eq!(resp.status(), StatusCode::OK, "query {q}");
    4068           12 :                 body_json(resp).await.to_string()
    4069           12 :             }
    4070           12 :         };
    4071              :         // csf on a Context Source Property
    4072            4 :         let got = list("csf=endpoint%3D%3D%22http%3A%2F%2Fa.example.com%22".into()).await;
    4073            4 :         assert!(
    4074            4 :             got.contains("disc-a") && !got.contains("disc-b"),
    4075              :             "csf: {got}"
    4076              :         );
    4077              :         // Scope query against the registration scope
    4078            4 :         let got = list("scopeQ=%2FMadrid%2F%23".into()).await;
    4079            4 :         assert!(
    4080            4 :             got.contains("disc-a") && !got.contains("disc-b"),
    4081              :             "scopeQ: {got}"
    4082              :         );
    4083              :         // geoquery against the registration location
    4084            4 :         let got = list(
    4085            4 :             "georel=near%3BmaxDistance%3D%3D2000&geometry=Point&coordinates=%5B8.68%2C49.41%5D"
    4086            4 :                 .into(),
    4087            4 :         )
    4088            4 :         .await;
    4089            4 :         assert!(
    4090            4 :             got.contains("disc-a") && !got.contains("disc-b"),
    4091            4 :             "geo: {got}"
    4092            4 :         );
    4093            4 :     }
    4094              : 
    4095              :     // ---- Table 6.4.3.2-1: type=* ------------------------------------------
    4096              : 
    4097              :     #[tokio::test]
    4098            4 :     async fn type_wildcard_selects_every_type() {
    4099              :         // "\"*\" is also allowed as a value and local is implicitly set to
    4100              :         // true". Expanding "*" as a term produced an IRI nothing matched, so
    4101              :         // the query returned 200 with an empty array.
    4102            4 :         let app = app();
    4103            4 :         create(&app, "urn:ngsi-ld:Building:star1", "Building").await;
    4104            4 :         create(&app, "urn:ngsi-ld:Vehicle:star2", "Vehicle").await;
    4105              : 
    4106            4 :         let resp = app
    4107            4 :             .clone()
    4108            4 :             .oneshot(
    4109            4 :                 Request::get("/ngsi-ld/v1/entities?type=%2A")
    4110            4 :                     .body(Body::empty())
    4111            4 :                     .expect("req"),
    4112            4 :             )
    4113            4 :             .await
    4114            4 :             .expect("resp");
    4115            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4116            4 :         let docs = body_json(resp).await;
    4117            4 :         let ids: Vec<&str> = docs
    4118            4 :             .as_array()
    4119            4 :             .expect("array")
    4120            4 :             .iter()
    4121            8 :             .filter_map(|d| d["id"].as_str())
    4122            4 :             .collect();
    4123            4 :         assert!(ids.contains(&"urn:ngsi-ld:Building:star1"), "got {ids:?}");
    4124            4 :         assert!(ids.contains(&"urn:ngsi-ld:Vehicle:star2"), "got {ids:?}");
    4125            4 :     }
    4126              : 
    4127              :     #[tokio::test]
    4128            4 :     async fn type_wildcard_conflicts_with_explicit_local_false() {
    4129              :         // "…and shall not be explicitly set to false" (Table 6.4.3.2-1)
    4130            4 :         let app = app();
    4131            4 :         let resp = app
    4132            4 :             .oneshot(
    4133            4 :                 Request::get("/ngsi-ld/v1/entities?type=%2A&local=false")
    4134            4 :                     .body(Body::empty())
    4135            4 :                     .expect("req"),
    4136            4 :             )
    4137            4 :             .await
    4138            4 :             .expect("resp");
    4139            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    4140            4 :     }
    4141              : 
    4142              :     // ---- 5.7.2.4 validation bullets ---------------------------------------
    4143              : 
    4144              :     #[tokio::test]
    4145            4 :     async fn ordering_is_rejected_only_for_distributed_execution() {
    4146              :         // 5.7.2.4: "If the ordering parameter is present and the execution of
    4147              :         // the operation is not limited to the local scope … BadRequestData",
    4148              :         // with 4.23.1 "Sort ordering is never applied to distributed
    4149              :         // operations". The subject is the EXECUTION — a query no registration
    4150              :         // matches runs locally whether or not the client passed local=true.
    4151              :         // Reading it as "local=true is mandatory" fails ETSI 019_19, which
    4152              :         // orders without it.
    4153            4 :         let app = app();
    4154              : 
    4155              :         // no registrations → local execution → ordering is fine
    4156            8 :         for q in [
    4157            4 :             "/ngsi-ld/v1/entities?type=Building&orderBy=name",
    4158            4 :             "/ngsi-ld/v1/entities?type=Building&orderBy=name&local=true",
    4159            4 :         ] {
    4160            8 :             let resp = app
    4161            8 :                 .clone()
    4162            8 :                 .oneshot(Request::get(q).body(Body::empty()).expect("req"))
    4163            8 :                 .await
    4164            8 :                 .expect("resp");
    4165            8 :             assert_eq!(resp.status(), StatusCode::OK, "{q}");
    4166              :         }
    4167              : 
    4168              :         // a matching registration makes it a distributed operation
    4169            4 :         let csr = serde_json::json!({
    4170            4 :             "id": "urn:ngsi-ld:ContextSourceRegistration:ord1",
    4171            4 :             "type": "ContextSourceRegistration",
    4172            4 :             "information": [{"entities": [{"type": "Building"}]}],
    4173            4 :             "endpoint": "http://peer.invalid:9090"
    4174              :         });
    4175            4 :         let resp = app
    4176            4 :             .clone()
    4177            4 :             .oneshot(
    4178            4 :                 Request::post("/ngsi-ld/v1/csourceRegistrations")
    4179            4 :                     .header("Content-Type", "application/json")
    4180            4 :                     .header("Content-Length", (csr.to_string()).len())
    4181            4 :                     .body(Body::from(csr.to_string()))
    4182            4 :                     .expect("req"),
    4183            4 :             )
    4184            4 :             .await
    4185            4 :             .expect("resp");
    4186            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    4187              : 
    4188            4 :         let resp = app
    4189            4 :             .clone()
    4190            4 :             .oneshot(
    4191            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building&orderBy=name")
    4192            4 :                     .body(Body::empty())
    4193            4 :                     .expect("req"),
    4194            4 :             )
    4195            4 :             .await
    4196            4 :             .expect("resp");
    4197            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    4198              : 
    4199              :         // …and local=true brings it back into scope
    4200            4 :         let resp = app
    4201            4 :             .oneshot(
    4202            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building&orderBy=name&local=true")
    4203            4 :                     .body(Body::empty())
    4204            4 :                     .expect("req"),
    4205            4 :             )
    4206            4 :             .await
    4207            4 :             .expect("resp");
    4208            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4209            4 :     }
    4210              : 
    4211              :     #[tokio::test]
    4212            4 :     async fn geometry_property_requires_geojson_accept() {
    4213              :         // 5.7.2.4: "If geometryProperty parameter is present and the Accept
    4214              :         // Header is not set to \"application/geo+json\" … BadRequestData"
    4215            4 :         let app = app();
    4216            4 :         let resp = app
    4217            4 :             .clone()
    4218            4 :             .oneshot(
    4219            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building&geometryProperty=location")
    4220            4 :                     .body(Body::empty())
    4221            4 :                     .expect("req"),
    4222            4 :             )
    4223            4 :             .await
    4224            4 :             .expect("resp");
    4225            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    4226              : 
    4227            4 :         let resp = app
    4228            4 :             .oneshot(
    4229            4 :                 Request::get(
    4230            4 :                     "/ngsi-ld/v1/entities?type=Building&geometryProperty=location&local=true",
    4231            4 :                 )
    4232            4 :                 .header("Accept", "application/geo+json")
    4233            4 :                 .body(Body::empty())
    4234            4 :                 .expect("req"),
    4235            4 :             )
    4236            4 :             .await
    4237            4 :             .expect("resp");
    4238            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4239            4 :     }
    4240              : 
    4241              :     // ---- 6.3.4: Content-Length precondition -------------------------------
    4242              : 
    4243              :     #[tokio::test]
    4244            4 :     async fn missing_content_length_is_a_bare_411() {
    4245              :         // 6.3.4: "For HTTP POST, PATCH and PUT HTTP requests implementations
    4246              :         // shall check … Content-Length header shall include the length of the
    4247              :         // request payload body", and its absence "shall result in just a 411
    4248              :         // HTTP status code (without any payload body)". No exemption is given
    4249              :         // for chunked transfer.
    4250            4 :         let app = app();
    4251            4 :         let entity = serde_json::json!({"id": "urn:ngsi-ld:Building:cl1", "type": "Building"});
    4252              : 
    4253           12 :         for (method, uri) in [
    4254            4 :             ("POST", "/ngsi-ld/v1/entities"),
    4255            4 :             (
    4256            4 :                 "PATCH",
    4257            4 :                 "/ngsi-ld/v1/entities/urn:ngsi-ld:Building:cl1/attrs",
    4258            4 :             ),
    4259            4 :             ("PUT", "/ngsi-ld/v1/entities/urn:ngsi-ld:Building:cl1"),
    4260            4 :         ] {
    4261           12 :             let resp = app
    4262           12 :                 .clone()
    4263           12 :                 .oneshot(
    4264           12 :                     Request::builder()
    4265           12 :                         .method(method)
    4266           12 :                         .uri(uri)
    4267           12 :                         .header("Content-Type", "application/json")
    4268           12 :                         .body(Body::from(entity.to_string()))
    4269           12 :                         .expect("req"),
    4270           12 :                 )
    4271           12 :                 .await
    4272           12 :                 .expect("resp");
    4273           12 :             assert_eq!(resp.status(), StatusCode::LENGTH_REQUIRED, "{method} {uri}");
    4274           12 :             let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    4275           12 :             assert!(bytes.is_empty(), "411 carries no payload body");
    4276              :         }
    4277              : 
    4278              :         // GET/DELETE are outside the clause's scope
    4279            4 :         let resp = app
    4280            4 :             .oneshot(
    4281            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building")
    4282            4 :                     .body(Body::empty())
    4283            4 :                     .expect("req"),
    4284            4 :             )
    4285            4 :             .await
    4286            4 :             .expect("resp");
    4287            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4288            4 :     }
    4289              : 
    4290              :     // ---- Table 5.2.40-1: Context Source Identity --------------------------
    4291              : 
    4292              :     #[tokio::test]
    4293            4 :     async fn source_identity_carries_the_mandated_members() {
    4294              :         // contextSourceAlias / contextSourceUptime / contextSourceTimeAt are
    4295              :         // all cardinality 1. The old payload used hostAlias/uptime, which are
    4296              :         // not core-context terms at all.
    4297            4 :         let resp = app()
    4298            4 :             .oneshot(
    4299            4 :                 Request::get("/ngsi-ld/v1/info/sourceIdentity")
    4300            4 :                     .body(Body::empty())
    4301            4 :                     .expect("req"),
    4302            4 :             )
    4303            4 :             .await
    4304            4 :             .expect("resp");
    4305            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4306            4 :         let doc = body_json(resp).await;
    4307            4 :         assert_eq!(doc["type"], "ContextSourceIdentity");
    4308            4 :         assert!(doc["id"].as_str().is_some_and(|s| s.starts_with("urn:")));
    4309            4 :         assert_eq!(doc["contextSourceAlias"], "antares-test");
    4310            4 :         assert!(
    4311            4 :             doc["contextSourceUptime"]
    4312            4 :                 .as_str()
    4313            4 :                 .is_some_and(|s| s.starts_with("PT") && s.ends_with('S')),
    4314              :             "uptime must be an ISO 8601 duration, got {:?}",
    4315            0 :             doc["contextSourceUptime"]
    4316              :         );
    4317            4 :         assert!(
    4318            4 :             doc["contextSourceTimeAt"]
    4319            4 :                 .as_str()
    4320            4 :                 .is_some_and(|s| s.ends_with('Z')),
    4321              :             "timeAt must be a 4.6.3 DateTime, got {:?}",
    4322            0 :             doc["contextSourceTimeAt"]
    4323              :         );
    4324            4 :         assert!(
    4325            4 :             doc.get("hostAlias").is_none(),
    4326              :             "hostAlias is not a spec member"
    4327              :         );
    4328            4 :         assert!(doc.get("uptime").is_none(), "uptime is not a spec member");
    4329            4 :     }
    4330              : 
    4331              :     // ---- 6.3.4: Accept precedence -----------------------------------------
    4332              : 
    4333              :     #[tokio::test]
    4334            4 :     async fn accept_precedence_follows_the_spec_list_not_header_order() {
    4335              :         // "The order of the list above is significant … the first one of the
    4336              :         // list shall be selected, unless amended by … a q parameter."
    4337              :         // json > ld+json > geo+json, regardless of how the client orders them.
    4338            4 :         let app = app();
    4339            4 :         create(&app, "urn:ngsi-ld:Building:acc1", "Building").await;
    4340              : 
    4341           16 :         for (accept, want) in [
    4342            4 :             ("application/ld+json, application/json", "application/json"),
    4343            4 :             ("application/geo+json, application/json", "application/json"),
    4344            4 :             ("application/ld+json", "application/ld+json"),
    4345            4 :             // an explicit q still wins over list order
    4346            4 :             (
    4347            4 :                 "application/json;q=0.1, application/ld+json;q=0.9",
    4348            4 :                 "application/ld+json",
    4349            4 :             ),
    4350            4 :         ] {
    4351           16 :             let resp = app
    4352           16 :                 .clone()
    4353           16 :                 .oneshot(
    4354           16 :                     Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:acc1")
    4355           16 :                         .header("Accept", accept)
    4356           16 :                         .body(Body::empty())
    4357           16 :                         .expect("req"),
    4358           16 :                 )
    4359           16 :                 .await
    4360           16 :                 .expect("resp");
    4361           16 :             let ct = resp
    4362           16 :                 .headers()
    4363           16 :                 .get("Content-Type")
    4364           16 :                 .and_then(|v| v.to_str().ok())
    4365           16 :                 .unwrap_or_default()
    4366           16 :                 .to_owned();
    4367           16 :             assert!(
    4368           16 :                 ct.starts_with(want),
    4369            4 :                 "Accept: {accept} → Content-Type {ct}, expected {want}"
    4370            4 :             );
    4371            4 :         }
    4372            4 :     }
    4373              : 
    4374              :     /// 6.3.10 p.275: "At least, the type Link Target Attribute shall be
    4375              :     /// included ... and its value shall be exactly equal to the media type
    4376              :     /// resulting from the original request" — previously emitted only for
    4377              :     /// ld+json, never for plain application/json.
    4378              :     #[tokio::test]
    4379            4 :     async fn pagination_links_carry_the_type_attribute_for_plain_json() {
    4380            4 :         let app = app();
    4381           12 :         for i in 0..3 {
    4382           12 :             let entity = serde_json::json!({
    4383           12 :                 "id": format!("urn:ngsi-ld:Building:pg{i}"),
    4384           12 :                 "type": "Building"
    4385              :             });
    4386           12 :             let resp = app
    4387           12 :                 .clone()
    4388           12 :                 .oneshot(
    4389           12 :                     Request::post("/ngsi-ld/v1/entities")
    4390           12 :                         .header("Content-Type", "application/json")
    4391           12 :                         .header("Content-Length", (entity.to_string()).len())
    4392           12 :                         .body(Body::from(entity.to_string()))
    4393           12 :                         .expect("req"),
    4394           12 :                 )
    4395           12 :                 .await
    4396           12 :                 .expect("resp");
    4397           12 :             assert_eq!(resp.status(), StatusCode::CREATED);
    4398              :         }
    4399            4 :         let resp = app
    4400            4 :             .clone()
    4401            4 :             .oneshot(
    4402            4 :                 Request::get("/ngsi-ld/v1/entities?type=Building&limit=1&offset=1")
    4403            4 :                     .body(Body::empty())
    4404            4 :                     .expect("req"),
    4405            4 :             )
    4406            4 :             .await
    4407            4 :             .expect("resp");
    4408            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4409            4 :         let links: Vec<String> = resp
    4410            4 :             .headers()
    4411            4 :             .get_all("Link")
    4412            4 :             .iter()
    4413           12 :             .filter_map(|v| v.to_str().ok())
    4414           12 :             .flat_map(|v| v.split(", "))
    4415           12 :             .filter(|l| l.contains("rel=\"next\"") || l.contains("rel=\"prev\""))
    4416            4 :             .map(str::to_owned)
    4417            4 :             .collect();
    4418            4 :         assert_eq!(links.len(), 2, "expected next+prev, got {links:?}");
    4419            8 :         for l in &links {
    4420            8 :             assert!(
    4421            8 :                 l.contains(";type=\"application/json\""),
    4422            4 :                 "pagination link lacks the mandatory type attribute: {l}"
    4423            4 :             );
    4424            4 :         }
    4425            4 :     }
    4426              : 
    4427              :     /// 4.5.9 p.63/65: in the simplified temporal representation
    4428              :     /// a ListProperty pairs a BARE ordered array with its timestamp under
    4429              :     /// `valueLists` (EXAMPLE 3), and a ListRelationship the same under
    4430              :     /// `objectLists` — not a {"valueList"/"objectList"} wrapper object.
    4431              :     #[tokio::test]
    4432            4 :     async fn temporal_values_list_types_use_bare_arrays() {
    4433            4 :         let app = app();
    4434            4 :         let doc = serde_json::json!({
    4435            4 :             "id": "urn:ngsi-ld:Meeting:tv1",
    4436            4 :             "type": "Meeting",
    4437            4 :             "period": [
    4438            4 :                 {"type": "ListProperty", "valueList": ["First", "Second"],
    4439            4 :                  "observedAt": "2023-01-01T00:00:00Z"},
    4440            4 :                 {"type": "ListProperty", "valueList": ["1st", "2nd"],
    4441            4 :                  "observedAt": "2023-01-02T00:00:00Z"}
    4442              :             ],
    4443            4 :             "membersPresent": [
    4444            4 :                 {"type": "ListRelationship",
    4445            4 :                  "objectList": ["urn:ngsi-ld:Person:Alice", "urn:ngsi-ld:Person:Bob"],
    4446            4 :                  "observedAt": "2023-01-01T00:00:00Z"}
    4447              :             ]
    4448              :         });
    4449            4 :         let resp = app
    4450            4 :             .clone()
    4451            4 :             .oneshot(
    4452            4 :                 Request::post("/ngsi-ld/v1/temporal/entities")
    4453            4 :                     .header("Content-Type", "application/json")
    4454            4 :                     .header("Content-Length", (doc.to_string()).len())
    4455            4 :                     .body(Body::from(doc.to_string()))
    4456            4 :                     .expect("req"),
    4457            4 :             )
    4458            4 :             .await
    4459            4 :             .expect("resp");
    4460            4 :         assert!(
    4461            4 :             resp.status() == StatusCode::CREATED || resp.status() == StatusCode::NO_CONTENT,
    4462              :             "temporal upsert failed: {}",
    4463            0 :             resp.status()
    4464              :         );
    4465            4 :         let resp = app
    4466            4 :             .clone()
    4467            4 :             .oneshot(
    4468            4 :                 Request::get(
    4469            4 :                     "/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:Meeting:tv1?format=temporalValues",
    4470            4 :                 )
    4471            4 :                 .body(Body::empty())
    4472            4 :                 .expect("req"),
    4473            4 :             )
    4474            4 :             .await
    4475            4 :             .expect("resp");
    4476            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4477            4 :         let body = body_json(resp).await;
    4478            4 :         let vl = &body["period"]["valueLists"];
    4479            4 :         assert!(vl.is_array(), "period.valueLists missing: {body}");
    4480            4 :         assert_eq!(
    4481            4 :             vl[0][0],
    4482            4 :             serde_json::json!(["First", "Second"]),
    4483              :             "first pair element must be the BARE ordered array: {vl}"
    4484              :         );
    4485            4 :         assert_eq!(vl[0][1], "2023-01-01T00:00:00Z");
    4486            4 :         let ol = &body["membersPresent"]["objectLists"];
    4487            4 :         assert_eq!(
    4488            4 :             ol[0][0],
    4489            4 :             serde_json::json!(["urn:ngsi-ld:Person:Alice", "urn:ngsi-ld:Person:Bob"]),
    4490            4 :             "objectLists pairs carry the bare URI array: {ol}"
    4491            4 :         );
    4492            4 :     }
    4493              : 
    4494              :     /// 4.5.19.1 Table -1 + 5.7.4.4 p.211: string-valued Properties
    4495              :     /// aggregate min/max lexicographically ("first/last value in
    4496              :     /// lexicographical order"); a method the datatype is not eligible for
    4497              :     /// ("sum" on strings is N/A) raises InvalidRequest; and numeric folds
    4498              :     /// never leak f64::INFINITY (serialized as null) into the payload.
    4499              :     #[tokio::test]
    4500            4 :     async fn aggregation_dispatches_on_datatype_and_rejects_ineligible() {
    4501            4 :         let app = app();
    4502            4 :         let doc = serde_json::json!({
    4503            4 :             "id": "urn:ngsi-ld:Building:agg1",
    4504            4 :             "type": "Building",
    4505            4 :             "operator": [
    4506            4 :                 {"type": "Property", "value": "alpha", "observedAt": "2023-01-01T00:00:00Z"},
    4507            4 :                 {"type": "Property", "value": "zulu",  "observedAt": "2023-01-02T00:00:00Z"},
    4508            4 :                 {"type": "Property", "value": "mike",  "observedAt": "2023-01-03T00:00:00Z"}
    4509              :             ]
    4510              :         });
    4511            4 :         let resp = app
    4512            4 :             .clone()
    4513            4 :             .oneshot(
    4514            4 :                 Request::post("/ngsi-ld/v1/temporal/entities")
    4515            4 :                     .header("Content-Type", "application/json")
    4516            4 :                     .header("Content-Length", (doc.to_string()).len())
    4517            4 :                     .body(Body::from(doc.to_string()))
    4518            4 :                     .expect("req"),
    4519            4 :             )
    4520            4 :             .await
    4521            4 :             .expect("resp");
    4522            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    4523            4 :         let base = "/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:Building:agg1\
    4524            4 :                     ?options=aggregatedValues&timerel=after&timeAt=2022-01-01T00:00:00Z";
    4525              :         // eligible: lexicographic min/max on strings
    4526            4 :         let resp = app
    4527            4 :             .clone()
    4528            4 :             .oneshot(
    4529            4 :                 Request::get(format!("{base}&aggrMethods=min,max"))
    4530            4 :                     .body(Body::empty())
    4531            4 :                     .expect("req"),
    4532            4 :             )
    4533            4 :             .await
    4534            4 :             .expect("resp");
    4535            4 :         assert_eq!(resp.status(), StatusCode::OK);
    4536            4 :         let body = body_json(resp).await;
    4537            4 :         assert_eq!(body["operator"]["min"][0][0], "alpha", "{body}");
    4538            4 :         assert_eq!(body["operator"]["max"][0][0], "zulu", "{body}");
    4539              :         // ineligible: sum over strings is N/A → InvalidRequest (400)
    4540            4 :         let resp = app
    4541            4 :             .clone()
    4542            4 :             .oneshot(
    4543            4 :                 Request::get(format!("{base}&aggrMethods=sum"))
    4544            4 :                     .body(Body::empty())
    4545            4 :                     .expect("req"),
    4546            4 :             )
    4547            4 :             .await
    4548            4 :             .expect("resp");
    4549            4 :         assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    4550            4 :         let body = body_json(resp).await;
    4551            4 :         assert_eq!(
    4552            4 :             body["type"], "https://uri.etsi.org/ngsi-ld/errors/InvalidRequest",
    4553            4 :             "{body}"
    4554            4 :         );
    4555            4 :     }
    4556              : }
    4557              : 
    4558              : #[cfg(test)]
    4559              : mod clause_5_2_8 {
    4560              :     use super::*;
    4561              :     use axum::body::Body;
    4562              :     use axum::http::{Request, StatusCode};
    4563              :     use tower::ServiceExt;
    4564              : 
    4565           20 :     async fn post_reg(entities: serde_json::Value) -> StatusCode {
    4566           20 :         let app = router(AppState::new("t528".into()));
    4567           20 :         let body = serde_json::json!({
    4568           20 :             "id": format!("urn:ngsi-ld:ContextSourceRegistration:528-{}",
    4569           20 :                           entities.to_string().len()),
    4570           20 :             "type": "ContextSourceRegistration",
    4571           20 :             "information": [{"entities": entities}],
    4572           20 :             "endpoint": "http://cs.example.org:1026"
    4573              :         })
    4574           20 :         .to_string();
    4575           20 :         let req = Request::post("/ngsi-ld/v1/csourceRegistrations")
    4576           20 :             .header("Content-Type", "application/json")
    4577           20 :             .header("Content-Length", body.len())
    4578           20 :             .body(Body::from(body))
    4579           20 :             .expect("req");
    4580           20 :         app.oneshot(req).await.expect("resp").status()
    4581           20 :     }
    4582              : 
    4583              :     /// Table 5.2.8-1: type is "String or String[]" — the array form must be
    4584              :     /// accepted; id must be a URI; idPattern must be a valid regex.
    4585              :     #[tokio::test]
    4586            4 :     async fn entity_info_type_accepts_the_array_form() {
    4587            4 :         assert_eq!(
    4588            4 :             post_reg(serde_json::json!([{"type": ["Building", "Vehicle"]}])).await,
    4589              :             StatusCode::CREATED,
    4590              :             "String[] type is legal"
    4591              :         );
    4592            4 :         assert_eq!(
    4593            4 :             post_reg(serde_json::json!([{"type": "Building"}])).await,
    4594              :             StatusCode::CREATED
    4595              :         );
    4596            4 :         assert_eq!(
    4597            4 :             post_reg(serde_json::json!([{"type": [] }])).await,
    4598              :             StatusCode::BAD_REQUEST,
    4599              :             "an empty type array names no Entity Type"
    4600              :         );
    4601            4 :         assert_eq!(
    4602            4 :             post_reg(serde_json::json!([{"type": "Building", "id": "not a uri"}])).await,
    4603              :             StatusCode::BAD_REQUEST
    4604              :         );
    4605            4 :         assert_eq!(
    4606            4 :             post_reg(serde_json::json!([{"type": "Building", "idPattern": "urn:[" }])).await,
    4607            4 :             StatusCode::BAD_REQUEST
    4608            4 :         );
    4609            4 :     }
    4610              : }
    4611              : 
    4612              : #[cfg(test)]
    4613              : mod clause_5_2_9 {
    4614              :     use super::*;
    4615              :     use axum::body::Body;
    4616              :     use axum::http::{Request, StatusCode};
    4617              :     use tower::ServiceExt;
    4618              : 
    4619           68 :     async fn post_reg(extra: serde_json::Value) -> StatusCode {
    4620           68 :         let app = router(AppState::new("t529".into()));
    4621           68 :         let mut doc = serde_json::json!({
    4622           68 :             "id": format!("urn:ngsi-ld:ContextSourceRegistration:529-{}",
    4623           68 :                           extra.to_string().len()),
    4624           68 :             "type": "ContextSourceRegistration",
    4625           68 :             "information": [{"entities": [{"type": "Building"}]}],
    4626           68 :             "endpoint": "http://cs.example.org:1026"
    4627              :         });
    4628           68 :         for (k, v) in extra.as_object().expect("obj") {
    4629           68 :             doc[k] = v.clone();
    4630           68 :         }
    4631           68 :         let body = doc.to_string();
    4632           68 :         let req = Request::post("/ngsi-ld/v1/csourceRegistrations")
    4633           68 :             .header("Content-Type", "application/json")
    4634           68 :             .header("Content-Length", body.len())
    4635           68 :             .body(Body::from(body))
    4636           68 :             .expect("req");
    4637           68 :         app.oneshot(req).await.expect("resp").status()
    4638           68 :     }
    4639              : 
    4640              :     /// Table 5.2.9-1 value spaces: operations limited to the 4.20 names and
    4641              :     /// groups; localOnly boolean; contextSourceAlias a non-empty RFC 7230
    4642              :     /// pseudonym token; refreshRate an ISO 8601 duration; datasetId URIs or
    4643              :     /// @none; scope per the 4.18 grammar; geometries per 4.7; description
    4644              :     /// and registrationName non-empty strings.
    4645              :     #[tokio::test]
    4646            4 :     async fn registration_member_value_spaces() {
    4647              :         use serde_json::json;
    4648            4 :         let cases: &[(serde_json::Value, StatusCode)] = &[
    4649            4 :             (json!({"operations": ["bogusOp"]}), StatusCode::BAD_REQUEST),
    4650            4 :             (
    4651            4 :                 json!({"operations": ["updateOps", "retrieveEntity"]}),
    4652            4 :                 StatusCode::CREATED,
    4653            4 :             ),
    4654            4 :             (json!({"localOnly": "yes"}), StatusCode::BAD_REQUEST),
    4655            4 :             (json!({"localOnly": true}), StatusCode::CREATED),
    4656            4 :             (json!({"contextSourceAlias": ""}), StatusCode::BAD_REQUEST),
    4657            4 :             (
    4658            4 :                 json!({"contextSourceAlias": "has space"}),
    4659            4 :                 StatusCode::BAD_REQUEST,
    4660            4 :             ),
    4661            4 :             (json!({"contextSourceAlias": "cs1"}), StatusCode::CREATED),
    4662            4 :             (json!({"refreshRate": "5 minutes"}), StatusCode::BAD_REQUEST),
    4663            4 :             (json!({"refreshRate": "PT5M"}), StatusCode::CREATED),
    4664            4 :             (
    4665            4 :                 json!({"datasetId": ["urn:ds:1", "@none"]}),
    4666            4 :                 StatusCode::CREATED,
    4667            4 :             ),
    4668            4 :             (json!({"datasetId": ["not a uri"]}), StatusCode::BAD_REQUEST),
    4669            4 :             (json!({"scope": "9bad"}), StatusCode::BAD_REQUEST),
    4670            4 :             (json!({"scope": ["/Madrid", "/A/B_2"]}), StatusCode::CREATED),
    4671            4 :             (json!({"description": ""}), StatusCode::BAD_REQUEST),
    4672            4 :             (json!({"registrationName": ""}), StatusCode::BAD_REQUEST),
    4673            4 :             (json!({"location": 5}), StatusCode::BAD_REQUEST),
    4674            4 :             (
    4675            4 :                 json!({"location": {"type": "Point", "coordinates": [8, 40]}}),
    4676            4 :                 StatusCode::CREATED,
    4677            4 :             ),
    4678            4 :         ];
    4679           68 :         for (extra, want) in cases {
    4680           68 :             let got = post_reg(extra.clone()).await;
    4681           68 :             assert_eq!(got, *want, "extra={extra}");
    4682            4 :         }
    4683            4 :     }
    4684              : 
    4685              :     /// 5.2.9, after Table 5.2.9-1: the Table 5.2.9-2 members "are read-only
    4686              :     /// and shall be automatically generated by NGSI-LD implementations. In
    4687              :     /// the event that they are provided (in update or create operations)
    4688              :     /// NGSI-LD implementations shall ignore them." A tolerant reader that
    4689              :     /// stores an unknown member verbatim would let a client dictate its own
    4690              :     /// forward history, and every one of these is served back.
    4691              :     #[tokio::test]
    4692            4 :     async fn table_5_2_9_2_members_are_ignored_on_create_and_update() {
    4693              :         use serde_json::json;
    4694              :         const READ_ONLY: [&str; 5] = [
    4695              :             "timesSent",
    4696              :             "timesFailed",
    4697              :             "lastSuccess",
    4698              :             "lastFailure",
    4699              :             "status",
    4700              :         ];
    4701            4 :         let id = "urn:ngsi-ld:ContextSourceRegistration:529-readonly";
    4702            4 :         let st = AppState::new("t5292".into());
    4703            4 :         let claimed = json!({
    4704            4 :             "timesSent": 9_000,
    4705            4 :             "timesFailed": 0,
    4706            4 :             "lastSuccess": "2035-01-01T00:00:00Z",
    4707            4 :             "lastFailure": "2035-01-01T00:00:00Z",
    4708            4 :             "status": "ok",
    4709              :         });
    4710              : 
    4711            4 :         let mut doc = json!({
    4712            4 :             "id": id,
    4713            4 :             "type": "ContextSourceRegistration",
    4714            4 :             "information": [{"entities": [{"type": "Building"}]}],
    4715            4 :             "endpoint": "http://cs.example.org:1026",
    4716              :         });
    4717           20 :         for (k, v) in claimed.as_object().expect("obj") {
    4718           20 :             doc[k] = v.clone();
    4719           20 :         }
    4720            4 :         let body = doc.to_string();
    4721            4 :         let res = router(st.clone())
    4722            4 :             .oneshot(
    4723            4 :                 Request::post("/ngsi-ld/v1/csourceRegistrations")
    4724            4 :                     .header("Content-Type", "application/json")
    4725            4 :                     .header("Content-Length", body.len())
    4726            4 :                     .body(Body::from(body))
    4727            4 :                     .expect("req"),
    4728            4 :             )
    4729            4 :             .await
    4730            4 :             .expect("resp");
    4731            4 :         assert_eq!(res.status(), StatusCode::CREATED, "create");
    4732              : 
    4733            8 :         let served = |st: AppState| async move {
    4734            8 :             let res = router(st)
    4735            8 :                 .oneshot(
    4736            8 :                     Request::get(format!("/ngsi-ld/v1/csourceRegistrations/{id}"))
    4737            8 :                         .body(Body::empty())
    4738            8 :                         .expect("req"),
    4739            8 :                 )
    4740            8 :                 .await
    4741            8 :                 .expect("resp");
    4742            8 :             assert_eq!(res.status(), StatusCode::OK, "retrieve");
    4743            8 :             let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
    4744            8 :                 .await
    4745            8 :                 .expect("body");
    4746            8 :             serde_json::from_slice::<serde_json::Value>(&bytes).expect("json")
    4747           16 :         };
    4748              : 
    4749            4 :         let got = served(st.clone()).await;
    4750           20 :         for m in READ_ONLY {
    4751           20 :             assert!(
    4752           20 :                 got.get(m).is_none(),
    4753              :                 "create must ignore the read-only member {m}: {got}"
    4754              :             );
    4755              :         }
    4756              : 
    4757              :         // 5.9.3 update: the same members, through the merge patch.
    4758            4 :         let patch = claimed.to_string();
    4759            4 :         let res = router(st.clone())
    4760            4 :             .oneshot(
    4761            4 :                 Request::patch(format!("/ngsi-ld/v1/csourceRegistrations/{id}"))
    4762            4 :                     .header("Content-Type", "application/json")
    4763            4 :                     .header("Content-Length", patch.len())
    4764            4 :                     .body(Body::from(patch))
    4765            4 :                     .expect("req"),
    4766            4 :             )
    4767            4 :             .await
    4768            4 :             .expect("resp");
    4769            4 :         assert_eq!(res.status(), StatusCode::NO_CONTENT, "update");
    4770              : 
    4771            4 :         let got = served(st).await;
    4772           20 :         for m in READ_ONLY {
    4773           20 :             assert!(
    4774           20 :                 got.get(m).is_none(),
    4775            4 :                 "update must ignore the read-only member {m}: {got}"
    4776            4 :             );
    4777            4 :         }
    4778            4 :     }
    4779              : }
    4780              : 
    4781              : #[cfg(test)]
    4782              : mod clause_5_2_10 {
    4783              :     use super::*;
    4784              :     use axum::body::Body;
    4785              :     use axum::http::{Request, StatusCode};
    4786              :     use tower::ServiceExt;
    4787              : 
    4788           16 :     async fn post_info(info: serde_json::Value) -> StatusCode {
    4789           16 :         let app = router(AppState::new("t5210".into()));
    4790           16 :         let body = serde_json::json!({
    4791           16 :             "id": format!("urn:ngsi-ld:ContextSourceRegistration:5210-{}",
    4792           16 :                           info.to_string().len()),
    4793           16 :             "type": "ContextSourceRegistration",
    4794           16 :             "information": [info],
    4795           16 :             "endpoint": "http://cs.example.org:1026"
    4796              :         })
    4797           16 :         .to_string();
    4798           16 :         let req = Request::post("/ngsi-ld/v1/csourceRegistrations")
    4799           16 :             .header("Content-Type", "application/json")
    4800           16 :             .header("Content-Length", body.len())
    4801           16 :             .body(Body::from(body))
    4802           16 :             .expect("req");
    4803           16 :         app.oneshot(req).await.expect("resp").status()
    4804           16 :     }
    4805              : 
    4806              :     /// Table 5.2.10-1: empty arrays are not allowed for entities,
    4807              :     /// propertyNames or relationshipNames; non-empty name lists are fine.
    4808              :     #[tokio::test]
    4809            4 :     async fn registration_info_empty_arrays_are_rejected() {
    4810              :         use serde_json::json;
    4811            4 :         assert_eq!(
    4812            4 :             post_info(json!({"entities": []})).await,
    4813              :             StatusCode::BAD_REQUEST
    4814              :         );
    4815            4 :         assert_eq!(
    4816            4 :             post_info(json!({"propertyNames": []})).await,
    4817              :             StatusCode::BAD_REQUEST,
    4818              :             "empty propertyNames"
    4819              :         );
    4820            4 :         assert_eq!(
    4821            4 :             post_info(json!({"relationshipNames": []})).await,
    4822              :             StatusCode::BAD_REQUEST,
    4823              :             "empty relationshipNames"
    4824              :         );
    4825            4 :         assert_eq!(
    4826            4 :             post_info(json!({"entities": [{"type": "Building"}],
    4827            4 :                 "propertyNames": ["speed"],
    4828            4 :                 "relationshipNames": ["isParked"]}))
    4829            4 :             .await,
    4830            4 :             StatusCode::CREATED
    4831            4 :         );
    4832            4 :     }
    4833              : }
    4834              : 
    4835              : #[cfg(test)]
    4836              : mod clause_5_2_11 {
    4837              :     use super::*;
    4838              :     use axum::body::Body;
    4839              :     use axum::http::{Request, StatusCode};
    4840              :     use tower::ServiceExt;
    4841              : 
    4842           20 :     async fn post_interval(iv: serde_json::Value) -> StatusCode {
    4843           20 :         let app = router(AppState::new("t5211".into()));
    4844           20 :         let body = serde_json::json!({
    4845           20 :             "id": format!("urn:ngsi-ld:ContextSourceRegistration:5211-{}",
    4846           20 :                           iv.to_string().len()),
    4847           20 :             "type": "ContextSourceRegistration",
    4848           20 :             "information": [{"entities": [{"type": "Building"}]}],
    4849           20 :             "endpoint": "http://cs.example.org:1026",
    4850           20 :             "observationInterval": iv
    4851              :         })
    4852           20 :         .to_string();
    4853           20 :         let req = Request::post("/ngsi-ld/v1/csourceRegistrations")
    4854           20 :             .header("Content-Type", "application/json")
    4855           20 :             .header("Content-Length", body.len())
    4856           20 :             .body(Body::from(body))
    4857           20 :             .expect("req");
    4858           20 :         app.oneshot(req).await.expect("resp").status()
    4859           20 :     }
    4860              : 
    4861              :     /// Table 5.2.11-1: startAt is a mandatory DateTime; endAt optional but a
    4862              :     /// DateTime when present (absent = open interval).
    4863              :     #[tokio::test]
    4864            4 :     async fn time_interval_member_rules() {
    4865              :         use serde_json::json;
    4866            4 :         assert_eq!(
    4867            4 :             post_interval(json!({"endAt": "2030-01-01T00:00:00Z"})).await,
    4868              :             StatusCode::BAD_REQUEST,
    4869              :             "startAt mandatory"
    4870              :         );
    4871            4 :         assert_eq!(
    4872            4 :             post_interval(json!({"startAt": "2020-01-01"})).await,
    4873              :             StatusCode::BAD_REQUEST,
    4874              :             "a Date is not a DateTime"
    4875              :         );
    4876            4 :         assert_eq!(
    4877            4 :             post_interval(json!({"startAt": "2020-01-01T00:00:00Z",
    4878            4 :                 "endAt": "not a date"}))
    4879            4 :             .await,
    4880              :             StatusCode::BAD_REQUEST
    4881              :         );
    4882            4 :         assert_eq!(
    4883            4 :             post_interval(json!({"startAt": "2020-01-01T00:00:00Z"})).await,
    4884              :             StatusCode::CREATED,
    4885              :             "open interval"
    4886              :         );
    4887            4 :         assert_eq!(
    4888            4 :             post_interval(json!({"startAt": "2020-01-01T00:00:00Z",
    4889            4 :                 "endAt": "2030-01-01T00:00:00Z"}))
    4890            4 :             .await,
    4891            4 :             StatusCode::CREATED
    4892            4 :         );
    4893            4 :     }
    4894              : }
    4895              : 
    4896              : #[cfg(test)]
    4897              : mod clause_5_2_12 {
    4898              :     use super::*;
    4899              :     use axum::body::Body;
    4900              :     use axum::http::{Request, StatusCode};
    4901              :     use tower::ServiceExt;
    4902              : 
    4903           16 :     async fn post_sub(doc: serde_json::Value) -> StatusCode {
    4904           16 :         let app = router(AppState::new("t5212".into()));
    4905           16 :         let body = doc.to_string();
    4906           16 :         let req = Request::post("/ngsi-ld/v1/subscriptions")
    4907           16 :             .header("Content-Type", "application/json")
    4908           16 :             .header("Content-Length", body.len())
    4909           16 :             .body(Body::from(body))
    4910           16 :             .expect("req");
    4911           16 :         app.oneshot(req).await.expect("resp").status()
    4912           16 :     }
    4913              : 
    4914              :     /// 5.2.12: "At least one of (a) entities or (b) watchedAttributes shall
    4915              :     /// be present, unless the member localOnly is set to true".
    4916              :     #[tokio::test]
    4917            4 :     async fn local_only_waives_the_selector_requirement() {
    4918              :         use serde_json::json;
    4919           16 :         let base = |extra: serde_json::Value| {
    4920           16 :             let mut d = json!({
    4921           16 :                 "id": format!("urn:ngsi-ld:Subscription:5212-{}", extra.to_string().len()),
    4922           16 :                 "type": "Subscription",
    4923           16 :                 "notification": {"endpoint": {"uri": "http://client.example.org/cb"}}
    4924              :             });
    4925           16 :             for (k, v) in extra.as_object().expect("obj") {
    4926           16 :                 d[k] = v.clone();
    4927           16 :             }
    4928           16 :             d
    4929           16 :         };
    4930            4 :         assert_eq!(
    4931            4 :             post_sub(base(json!({}))).await,
    4932              :             StatusCode::BAD_REQUEST,
    4933              :             "no selector and no localOnly"
    4934              :         );
    4935            4 :         assert_eq!(
    4936            4 :             post_sub(base(json!({"localOnly": true}))).await,
    4937              :             StatusCode::CREATED,
    4938              :             "localOnly=true waives entities/watchedAttributes"
    4939              :         );
    4940            4 :         assert_eq!(
    4941            4 :             post_sub(base(json!({"localOnly": false}))).await,
    4942              :             StatusCode::BAD_REQUEST
    4943              :         );
    4944              :         // the exclusions stay intact
    4945            4 :         assert_eq!(
    4946            4 :             post_sub(base(json!({"watchedAttributes": ["speed"],
    4947            4 :                 "timeInterval": 5})))
    4948            4 :             .await,
    4949            4 :             StatusCode::BAD_REQUEST
    4950            4 :         );
    4951            4 :     }
    4952              : }
    4953              : 
    4954              : #[cfg(test)]
    4955              : mod clause_5_2_13 {
    4956              :     use super::*;
    4957              :     use axum::body::Body;
    4958              :     use axum::http::{Request, StatusCode};
    4959              :     use tower::ServiceExt;
    4960              : 
    4961           20 :     async fn post_geoq(geoq: serde_json::Value) -> StatusCode {
    4962           20 :         let app = router(AppState::new("t5213".into()));
    4963           20 :         let body = serde_json::json!({
    4964           20 :             "id": format!("urn:ngsi-ld:Subscription:5213-{}", geoq.to_string().len()),
    4965           20 :             "type": "Subscription",
    4966           20 :             "entities": [{"type": "Vehicle"}],
    4967           20 :             "geoQ": geoq,
    4968           20 :             "notification": {"endpoint": {"uri": "http://client.example.org/cb"}}
    4969              :         })
    4970           20 :         .to_string();
    4971           20 :         let req = Request::post("/ngsi-ld/v1/subscriptions")
    4972           20 :             .header("Content-Type", "application/json")
    4973           20 :             .header("Content-Length", body.len())
    4974           20 :             .body(Body::from(body))
    4975           20 :             .expect("req");
    4976           20 :         app.oneshot(req).await.expect("resp").status()
    4977           20 :     }
    4978              : 
    4979              :     /// Table 5.2.13-1: coordinates as JSON Array OR string form, geometry
    4980              :     /// from the legal set (no GeometryCollection), georel per 4.10,
    4981              :     /// geoproperty optional.
    4982              :     #[tokio::test]
    4983            4 :     async fn subscription_geoquery_member_rules() {
    4984              :         use serde_json::json;
    4985            4 :         assert_eq!(
    4986            4 :             post_geoq(json!({"georel": "near;maxDistance==2000",
    4987            4 :                 "geometry": "Point", "coordinates": [8, 40]}))
    4988            4 :             .await,
    4989              :             StatusCode::CREATED
    4990              :         );
    4991            4 :         assert_eq!(
    4992            4 :             post_geoq(json!({"georel": "within", "geometry": "Polygon",
    4993            4 :                 "coordinates": "[[[0,0],[4,0],[4,4],[0,4],[0,0]]]",
    4994            4 :                 "geoproperty": "observationSpace"}))
    4995            4 :             .await,
    4996              :             StatusCode::CREATED,
    4997              :             "string-encoded coordinates (4.7.1) are legal"
    4998              :         );
    4999            4 :         assert_eq!(
    5000            4 :             post_geoq(json!({"georel": "within",
    5001            4 :                 "geometry": "GeometryCollection", "coordinates": []}))
    5002            4 :             .await,
    5003              :             StatusCode::BAD_REQUEST
    5004              :         );
    5005            4 :         assert_eq!(
    5006            4 :             post_geoq(json!({"georel": "touches", "geometry": "Point",
    5007            4 :                 "coordinates": [8, 40]}))
    5008            4 :             .await,
    5009              :             StatusCode::BAD_REQUEST
    5010              :         );
    5011            4 :         assert_eq!(
    5012            4 :             post_geoq(json!({"geometry": "Point", "coordinates": [8, 40]})).await,
    5013            4 :             StatusCode::BAD_REQUEST,
    5014            4 :             "georel is mandatory"
    5015            4 :         );
    5016            4 :     }
    5017              : }
    5018              : 
    5019              : #[cfg(test)]
    5020              : mod clause_5_2_14 {
    5021              :     use super::*;
    5022              :     use axum::body::Body;
    5023              :     use axum::http::{Request, StatusCode};
    5024              :     use tower::ServiceExt;
    5025              : 
    5026           36 :     async fn post_notif(n: serde_json::Value) -> StatusCode {
    5027           36 :         let app = router(AppState::new("t5214".into()));
    5028           36 :         let mut notif = serde_json::json!({"endpoint": {"uri": "http://client.example.org/cb"}});
    5029           40 :         for (k, v) in n.as_object().expect("obj") {
    5030           40 :             notif[k] = v.clone();
    5031           40 :         }
    5032           36 :         let body = serde_json::json!({
    5033           36 :             "id": format!("urn:ngsi-ld:Subscription:5214-{}", n.to_string().len()),
    5034           36 :             "type": "Subscription",
    5035           36 :             "entities": [{"type": "Vehicle"}],
    5036           36 :             "notification": notif
    5037              :         })
    5038           36 :         .to_string();
    5039           36 :         let req = Request::post("/ngsi-ld/v1/subscriptions")
    5040           36 :             .header("Content-Type", "application/json")
    5041           36 :             .header("Content-Length", body.len())
    5042           36 :             .body(Body::from(body))
    5043           36 :             .expect("req");
    5044           36 :         app.oneshot(req).await.expect("resp").status()
    5045           36 :     }
    5046              : 
    5047              :     /// Table 5.2.14.1-1: join limited to flat/inline/@none, joinLevel a
    5048              :     /// positive integer, sysAttrs/showChanges booleans, attributes may not
    5049              :     /// name id/type/scope (it is "a synonym for pick, except that id, type,
    5050              :     /// scope are not allowed").
    5051              :     #[tokio::test]
    5052            4 :     async fn notification_params_value_spaces() {
    5053              :         use serde_json::json;
    5054            4 :         assert_eq!(
    5055            4 :             post_notif(json!({"join": "sideways"})).await,
    5056              :             StatusCode::BAD_REQUEST
    5057              :         );
    5058            4 :         assert_eq!(
    5059            4 :             post_notif(json!({"join": "inline"})).await,
    5060              :             StatusCode::CREATED
    5061              :         );
    5062            4 :         assert_eq!(
    5063            4 :             post_notif(json!({"joinLevel": 0})).await,
    5064              :             StatusCode::BAD_REQUEST
    5065              :         );
    5066            4 :         assert_eq!(
    5067            4 :             post_notif(json!({"joinLevel": 1.5})).await,
    5068              :             StatusCode::BAD_REQUEST
    5069              :         );
    5070            4 :         assert_eq!(
    5071            4 :             post_notif(json!({"join": "flat", "joinLevel": 2})).await,
    5072              :             StatusCode::CREATED
    5073              :         );
    5074            4 :         assert_eq!(
    5075            4 :             post_notif(json!({"sysAttrs": "yes"})).await,
    5076              :             StatusCode::BAD_REQUEST
    5077              :         );
    5078            4 :         assert_eq!(
    5079            4 :             post_notif(json!({"showChanges": "yes"})).await,
    5080              :             StatusCode::BAD_REQUEST
    5081              :         );
    5082            4 :         assert_eq!(
    5083            4 :             post_notif(json!({"attributes": ["id"]})).await,
    5084              :             StatusCode::BAD_REQUEST,
    5085              :             "attributes may not name id/type/scope"
    5086              :         );
    5087            4 :         assert_eq!(
    5088            4 :             post_notif(json!({"attributes": ["speed"]})).await,
    5089            4 :             StatusCode::CREATED
    5090            4 :         );
    5091            4 :     }
    5092              : }
    5093              : 
    5094              : #[cfg(test)]
    5095              : mod clause_5_2_14_2 {
    5096              :     use super::*;
    5097              :     use axum::body::Body;
    5098              :     use axum::http::{Request, StatusCode};
    5099              :     use http_body_util::BodyExt;
    5100              :     use tower::ServiceExt;
    5101              : 
    5102              :     /// Table 5.2.14.2-1: lastFailure/lastNotification/lastSuccess/timesSent
    5103              :     /// are output-only — provided ones "shall ignore them" on create, and
    5104              :     /// retrieval never echoes fabricated values.
    5105              :     #[tokio::test]
    5106            4 :     async fn output_only_members_are_ignored_on_input() {
    5107            4 :         let st = AppState::new("t52142".into());
    5108            4 :         let app = router(st);
    5109            4 :         let body = serde_json::json!({
    5110            4 :             "id": "urn:ngsi-ld:Subscription:52142",
    5111            4 :             "type": "Subscription",
    5112            4 :             "entities": [{"type": "Vehicle"}],
    5113            4 :             "notification": {
    5114            4 :                 "endpoint": {"uri": "http://client.example.org/cb"},
    5115            4 :                 "timesSent": 999,
    5116            4 :                 "timesFailed": 999,
    5117            4 :                 "lastNotification": "1999-01-01T00:00:00Z",
    5118            4 :                 "lastSuccess": "1999-01-01T00:00:00Z",
    5119            4 :                 "lastFailure": "1999-01-01T00:00:00Z"
    5120              :             }
    5121              :         })
    5122            4 :         .to_string();
    5123            4 :         let req = Request::post("/ngsi-ld/v1/subscriptions")
    5124            4 :             .header("Content-Type", "application/json")
    5125            4 :             .header("Content-Length", body.len())
    5126            4 :             .body(Body::from(body))
    5127            4 :             .expect("req");
    5128            4 :         let resp = app.clone().oneshot(req).await.expect("resp");
    5129            4 :         assert_eq!(
    5130            4 :             resp.status(),
    5131              :             StatusCode::CREATED,
    5132              :             "providing them is not an error"
    5133              :         );
    5134            4 :         let resp = app
    5135            4 :             .oneshot(
    5136            4 :                 Request::get("/ngsi-ld/v1/subscriptions/urn:ngsi-ld:Subscription:52142")
    5137            4 :                     .body(Body::empty())
    5138            4 :                     .expect("req"),
    5139            4 :             )
    5140            4 :             .await
    5141            4 :             .expect("resp");
    5142            4 :         assert_eq!(resp.status(), StatusCode::OK);
    5143            4 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    5144            4 :         let doc: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
    5145            4 :         let n = &doc["notification"];
    5146           20 :         for k in [
    5147            4 :             "timesSent",
    5148            4 :             "timesFailed",
    5149            4 :             "lastNotification",
    5150            4 :             "lastSuccess",
    5151            4 :             "lastFailure",
    5152            4 :         ] {
    5153           20 :             assert!(
    5154           20 :                 n.get(k).is_none(),
    5155            4 :                 "client-fabricated {k} must be ignored, got {}",
    5156            4 :                 n[k]
    5157            4 :             );
    5158            4 :         }
    5159            4 :     }
    5160              : }
    5161              : 
    5162              : #[cfg(test)]
    5163              : mod clause_5_2_15 {
    5164              :     use super::*;
    5165              :     use axum::body::Body;
    5166              :     use axum::http::{Request, StatusCode};
    5167              :     use tower::ServiceExt;
    5168              : 
    5169           28 :     async fn post_ep(ep: serde_json::Value) -> StatusCode {
    5170           28 :         let app = router(AppState::new("t5215".into()));
    5171           28 :         let body = serde_json::json!({
    5172           28 :             "id": format!("urn:ngsi-ld:Subscription:5215-{}", ep.to_string().len()),
    5173           28 :             "type": "Subscription",
    5174           28 :             "entities": [{"type": "Vehicle"}],
    5175           28 :             "notification": {"endpoint": ep}
    5176              :         })
    5177           28 :         .to_string();
    5178           28 :         let req = Request::post("/ngsi-ld/v1/subscriptions")
    5179           28 :             .header("Content-Type", "application/json")
    5180           28 :             .header("Content-Length", body.len())
    5181           28 :             .body(Body::from(body))
    5182           28 :             .expect("req");
    5183           28 :         app.oneshot(req).await.expect("resp").status()
    5184           28 :     }
    5185              : 
    5186              :     /// Table 5.2.15-1: uri mandatory, accept value space, cooldown/timeout
    5187              :     /// > 0, receiverInfo/notifierInfo as KeyValuePair[] (5.2.22).
    5188              :     #[tokio::test]
    5189            4 :     async fn endpoint_member_rules() {
    5190              :         use serde_json::json;
    5191            4 :         let uri = "http://client.example.org/cb";
    5192            4 :         assert_eq!(
    5193            4 :             post_ep(json!({"accept": "application/json"})).await,
    5194              :             StatusCode::BAD_REQUEST,
    5195              :             "uri mandatory"
    5196              :         );
    5197            4 :         assert_eq!(
    5198            4 :             post_ep(json!({"uri": uri, "accept": "text/plain"})).await,
    5199              :             StatusCode::BAD_REQUEST
    5200              :         );
    5201            4 :         assert_eq!(
    5202            4 :             post_ep(json!({"uri": uri, "cooldown": 0})).await,
    5203              :             StatusCode::BAD_REQUEST
    5204              :         );
    5205            4 :         assert_eq!(
    5206            4 :             post_ep(json!({"uri": uri, "timeout": -1})).await,
    5207              :             StatusCode::BAD_REQUEST
    5208              :         );
    5209            4 :         assert_eq!(
    5210            4 :             post_ep(json!({"uri": uri,
    5211            4 :                 "receiverInfo": [{"key": "Authorization", "value": "Bearer x"}],
    5212            4 :                 "cooldown": 500, "timeout": 3000}))
    5213            4 :             .await,
    5214              :             StatusCode::CREATED
    5215              :         );
    5216            4 :         assert_eq!(
    5217            4 :             post_ep(json!({"uri": uri, "receiverInfo": ["junk"]})).await,
    5218              :             StatusCode::BAD_REQUEST,
    5219              :             "receiverInfo entries must be {{key, value}} pairs"
    5220              :         );
    5221            4 :         assert_eq!(
    5222            4 :             post_ep(json!({"uri": uri, "notifierInfo": [{"novalue": true}]})).await,
    5223            4 :             StatusCode::BAD_REQUEST,
    5224            4 :             "notifierInfo entries must be {{key, value}} pairs"
    5225            4 :         );
    5226            4 :     }
    5227              : }
    5228              : 
    5229              : #[cfg(test)]
    5230              : mod clause_5_2_16 {
    5231              :     use super::*;
    5232              :     use axum::body::Body;
    5233              :     use axum::http::{Request, StatusCode};
    5234              :     use http_body_util::BodyExt;
    5235              :     use tower::ServiceExt;
    5236              : 
    5237              :     /// Tables 5.2.16-1/5.2.17-1: a partial batch failure answers 207 with
    5238              :     /// success = URI array and errors = BatchEntityError[] (entityId +
    5239              :     /// RFC 7807 ProblemDetails).
    5240              :     #[tokio::test]
    5241            4 :     async fn batch_result_and_entity_error_shapes() {
    5242            4 :         let app = router(AppState::new("t5216".into()));
    5243            4 :         let body = serde_json::json!([
    5244            4 :             {"id": "urn:ngsi-ld:V:ok", "type": "Vehicle"},
    5245            4 :             {"id": "not a uri", "type": "Vehicle"}
    5246              :         ])
    5247            4 :         .to_string();
    5248            4 :         let req = Request::post("/ngsi-ld/v1/entityOperations/create")
    5249            4 :             .header("Content-Type", "application/json")
    5250            4 :             .header("Content-Length", body.len())
    5251            4 :             .body(Body::from(body))
    5252            4 :             .expect("req");
    5253            4 :         let resp = app.oneshot(req).await.expect("resp");
    5254            4 :         assert_eq!(resp.status(), StatusCode::MULTI_STATUS);
    5255            4 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    5256            4 :         let doc: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
    5257            4 :         assert_eq!(doc["success"], serde_json::json!(["urn:ngsi-ld:V:ok"]));
    5258            4 :         let errs = doc["errors"].as_array().expect("errors array");
    5259            4 :         assert_eq!(errs.len(), 1);
    5260            4 :         let e = &errs[0];
    5261            4 :         assert!(e.get("entityId").is_some());
    5262            4 :         let pd = &e["error"];
    5263           12 :         for k in ["type", "title", "status"] {
    5264           12 :             assert!(
    5265           12 :                 pd.get(k).is_some(),
    5266            4 :                 "ProblemDetails member {k} missing: {pd}"
    5267            4 :             );
    5268            4 :         }
    5269            4 :         assert!(
    5270            4 :             pd["type"].as_str().unwrap_or("").contains("errors/"),
    5271            4 :             "error.type is the NGSI-LD error URI"
    5272            4 :         );
    5273            4 :     }
    5274              : }
    5275              : 
    5276              : #[cfg(test)]
    5277              : mod clause_5_2_18 {
    5278              :     use super::*;
    5279              :     use axum::body::Body;
    5280              :     use axum::http::{Request, StatusCode};
    5281              :     use http_body_util::BodyExt;
    5282              :     use tower::ServiceExt;
    5283              : 
    5284              :     /// Tables 5.2.18-1/5.2.19-1: partial attribute update answers 207 with
    5285              :     /// updated = names and notUpdated = {attributeName, reason}[].
    5286              :     #[tokio::test]
    5287            4 :     async fn update_result_and_not_updated_details_shape() {
    5288            4 :         let app = router(AppState::new("t5218".into()));
    5289            4 :         let body = serde_json::json!({"id": "urn:ngsi-ld:V:5218", "type": "Vehicle",
    5290            4 :             "speed": {"type": "Property", "value": 1}})
    5291            4 :         .to_string();
    5292            4 :         let req = Request::post("/ngsi-ld/v1/entities")
    5293            4 :             .header("Content-Type", "application/json")
    5294            4 :             .header("Content-Length", body.len())
    5295            4 :             .body(Body::from(body))
    5296            4 :             .expect("req");
    5297            4 :         assert_eq!(
    5298            4 :             app.clone().oneshot(req).await.expect("r").status(),
    5299              :             StatusCode::CREATED
    5300              :         );
    5301              :         // noOverwrite append: speed exists (skipped), brand is new (applied)
    5302            4 :         let frag = serde_json::json!({
    5303            4 :             "speed": {"type": "Property", "value": 2},
    5304            4 :             "brand": {"type": "Property", "value": "x"}})
    5305            4 :         .to_string();
    5306            4 :         let req =
    5307            4 :             Request::post("/ngsi-ld/v1/entities/urn:ngsi-ld:V:5218/attrs?options=noOverwrite")
    5308            4 :                 .header("Content-Type", "application/json")
    5309            4 :                 .header("Content-Length", frag.len())
    5310            4 :                 .body(Body::from(frag))
    5311            4 :                 .expect("req");
    5312            4 :         let resp = app.oneshot(req).await.expect("resp");
    5313            4 :         assert_eq!(resp.status(), StatusCode::MULTI_STATUS);
    5314            4 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    5315            4 :         let doc: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
    5316            4 :         let updated = doc["updated"].as_array().expect("updated");
    5317            4 :         assert!(
    5318            4 :             updated
    5319            4 :                 .iter()
    5320            4 :                 .any(|u| u.as_str().unwrap_or("").contains("brand")),
    5321              :             "brand applied: {doc}"
    5322              :         );
    5323            4 :         let nu = doc["notUpdated"].as_array().expect("notUpdated");
    5324            4 :         assert_eq!(nu.len(), 1, "{doc}");
    5325            4 :         assert!(nu[0]["attributeName"]
    5326            4 :             .as_str()
    5327            4 :             .unwrap_or("")
    5328            4 :             .contains("speed"));
    5329            4 :         assert!(!nu[0]["reason"].as_str().unwrap_or("").is_empty());
    5330            4 :         assert!(
    5331            4 :             nu[0].get("registrationId").is_none(),
    5332            4 :             "local failure carries no registrationId"
    5333            4 :         );
    5334            4 :     }
    5335              : }
    5336              : 
    5337              : #[cfg(test)]
    5338              : mod clause_5_2_21 {
    5339              :     use super::*;
    5340              :     use axum::body::Body;
    5341              :     use axum::http::{Request, StatusCode};
    5342              :     use http_body_util::BodyExt;
    5343              :     use serde_json::json;
    5344              :     use tower::ServiceExt;
    5345              : 
    5346           24 :     async fn temporal_query(qs: &str) -> StatusCode {
    5347           24 :         let app = router(AppState::new("t5221".into()));
    5348           24 :         let req = Request::get(format!(
    5349              :             "/ngsi-ld/v1/temporal/entities?type=Vehicle&timerel=after&timeAt=2020-01-01T00:00:00Z{qs}"
    5350              :         ))
    5351           24 :         .body(Body::empty())
    5352           24 :         .expect("req");
    5353           24 :         app.oneshot(req).await.expect("resp").status()
    5354           24 :     }
    5355              : 
    5356              :     /// Table 5.2.21-1: lastN is a POSITIVE integer; aggrMethods entries are
    5357              :     /// limited to the 4.5.19 methods; endTimeAt is mandatory for between.
    5358              :     #[tokio::test]
    5359            4 :     async fn temporal_query_member_value_spaces() {
    5360            4 :         assert_eq!(
    5361            4 :             temporal_query("&lastN=0").await,
    5362              :             StatusCode::BAD_REQUEST,
    5363              :             "lastN=0"
    5364              :         );
    5365            4 :         assert_eq!(temporal_query("&lastN=-3").await, StatusCode::BAD_REQUEST);
    5366            4 :         assert_eq!(temporal_query("&lastN=5").await, StatusCode::OK);
    5367            4 :         assert_eq!(
    5368            4 :             temporal_query("&aggrMethods=bogus").await,
    5369              :             StatusCode::BAD_REQUEST
    5370              :         );
    5371            4 :         assert_eq!(
    5372            4 :             temporal_query("&options=aggregatedValues").await,
    5373              :             StatusCode::BAD_REQUEST,
    5374              :             "aggregatedValues without aggrMethods"
    5375              :         );
    5376            4 :         assert_eq!(
    5377            4 :             temporal_query("&options=aggregatedValues&aggrMethods=avg,max").await,
    5378              :             StatusCode::OK
    5379              :         );
    5380            4 :         let app = router(AppState::new("t5221b".into()));
    5381            4 :         let req = Request::get(
    5382              :             "/ngsi-ld/v1/temporal/entities?type=V&timerel=between&timeAt=2020-01-01T00:00:00Z",
    5383              :         )
    5384            4 :         .body(Body::empty())
    5385            4 :         .expect("req");
    5386            4 :         assert_eq!(
    5387            4 :             app.oneshot(req).await.expect("resp").status(),
    5388            4 :             StatusCode::BAD_REQUEST,
    5389            4 :             "between without endTimeAt"
    5390            4 :         );
    5391            4 :     }
    5392              : 
    5393              :     /// POST Query (5.2.23) carrying a temporalQ object (5.2.21 JSON form).
    5394           56 :     async fn post_tq(tq: serde_json::Value, qs: &str) -> (StatusCode, String) {
    5395           56 :         let app = router(AppState::new("t5221j".into()));
    5396           56 :         let body = json!({
    5397           56 :             "type": "Query",
    5398           56 :             "entities": [{"type": "Vehicle"}],
    5399           56 :             "temporalQ": tq
    5400              :         })
    5401           56 :         .to_string();
    5402           56 :         let req = Request::post(format!("/ngsi-ld/v1/temporal/entityOperations/query{qs}"))
    5403           56 :             .header("Content-Type", "application/json")
    5404           56 :             .header("Content-Length", body.len())
    5405           56 :             .body(Body::from(body))
    5406           56 :             .expect("req");
    5407           56 :         let resp = app.oneshot(req).await.expect("resp");
    5408           56 :         let status = resp.status();
    5409           56 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    5410           56 :         (status, String::from_utf8_lossy(&bytes).into_owned())
    5411           56 :     }
    5412              : 
    5413              :     /// Table 5.2.21-1 (JSON form): lastN is a positive INTEGER — zero,
    5414              :     /// negative, fractional and string values are all outside the value
    5415              :     /// space; timerel is limited to before/after/between; endTimeAt is
    5416              :     /// mandatory for between; timeproperty is limited to the four 4.8 names.
    5417              :     #[tokio::test]
    5418            4 :     async fn temporal_query_json_member_value_spaces() {
    5419            4 :         let ok = json!({"timerel": "after", "timeAt": "2020-01-01T00:00:00Z"});
    5420           36 :         let with = |k: &str, v: serde_json::Value| {
    5421           36 :             let mut t = ok.clone();
    5422           36 :             t[k] = v;
    5423           36 :             t
    5424           36 :         };
    5425            4 :         let (st, body) = post_tq(with("lastN", json!(5)), "").await;
    5426            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    5427            4 :         assert!(!body.contains("BadRequestData"), "{body}");
    5428           16 :         for bad in [json!(0), json!(-3), json!(2.5), json!("5")] {
    5429           16 :             let (st, body) = post_tq(with("lastN", bad.clone()), "").await;
    5430           16 :             assert_eq!(st, StatusCode::BAD_REQUEST, "lastN={bad}");
    5431           16 :             assert!(body.contains("lastN"), "{body}");
    5432              :         }
    5433            4 :         let (st, _) = post_tq(with("timerel", json!("bogus")), "").await;
    5434            4 :         assert_eq!(st, StatusCode::BAD_REQUEST);
    5435            4 :         let (st, _) = post_tq(with("timerel", json!("between")), "").await;
    5436            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "between without endTimeAt");
    5437            4 :         let (st, _) = post_tq(with("timeproperty", json!("expiresAt")), "").await;
    5438            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "timeproperty outside 4.8 set");
    5439            4 :         let (st, _) = post_tq(with("timeAt", json!(20200101)), "").await;
    5440            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "timeAt must be a string");
    5441            4 :     }
    5442              : 
    5443              :     /// Table 5.2.21-1: aggrMethods (comma separated list of string — both
    5444              :     /// the string and string-array spellings) and aggrPeriodDuration are
    5445              :     /// carried by the JSON TemporalQuery and honoured when
    5446              :     /// aggregatedValues is requested via format/options.
    5447              :     #[tokio::test]
    5448            4 :     async fn temporal_query_json_aggregation_members() {
    5449            4 :         let base = json!({"timerel": "after", "timeAt": "2020-01-01T00:00:00Z"});
    5450           16 :         let with = |k: &str, v: serde_json::Value| {
    5451           16 :             let mut t = base.clone();
    5452           16 :             t[k] = v;
    5453           16 :             t
    5454           16 :         };
    5455            4 :         let (st, body) = post_tq(
    5456            4 :             with("aggrMethods", json!(["avg", "max"])),
    5457            4 :             "?format=aggregatedValues",
    5458              :         )
    5459            4 :         .await;
    5460            4 :         assert_eq!(st, StatusCode::OK, "array aggrMethods honoured: {body}");
    5461            4 :         let (st, body) = post_tq(
    5462            4 :             with("aggrMethods", json!("avg,max")),
    5463            4 :             "?format=aggregatedValues",
    5464              :         )
    5465            4 :         .await;
    5466            4 :         assert_eq!(st, StatusCode::OK, "string aggrMethods honoured: {body}");
    5467            4 :         let (st, body) = post_tq(
    5468            4 :             with("aggrMethods", json!(["bogus"])),
    5469            4 :             "?format=aggregatedValues",
    5470              :         )
    5471            4 :         .await;
    5472            4 :         assert_eq!(st, StatusCode::BAD_REQUEST);
    5473            4 :         assert!(body.contains("aggrMethods"), "{body}");
    5474            4 :         let (st, _) = post_tq(with("aggrMethods", json!(42)), "?format=aggregatedValues").await;
    5475            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "aggrMethods wrong JSON type");
    5476            4 :         let mut t = base.clone();
    5477            4 :         t["aggrMethods"] = json!(["avg"]);
    5478            4 :         t["aggrPeriodDuration"] = json!("bogus");
    5479            4 :         let (st, body) = post_tq(t, "?format=aggregatedValues").await;
    5480            4 :         assert_eq!(st, StatusCode::BAD_REQUEST);
    5481            4 :         assert!(body.contains("aggrPeriodDuration"), "{body}");
    5482            4 :     }
    5483              : 
    5484              :     /// 5.2.21 used from Subscription.temporalQ (5.2.12, CSR subscriptions):
    5485              :     /// timerel and timeAt are cardinality 1 — a temporalQ violating the
    5486              :     /// data type is rejected at subscription creation.
    5487              :     #[tokio::test]
    5488            4 :     async fn subscription_temporal_q_validated() {
    5489           20 :         async fn post_sub(tq: serde_json::Value) -> StatusCode {
    5490           20 :             let app = router(AppState::new("t5221s".into()));
    5491           20 :             let body = json!({
    5492           20 :                 "id": format!("urn:ngsi-ld:Subscription:5221-{}", tq.to_string().len()),
    5493           20 :                 "type": "Subscription",
    5494           20 :                 "entities": [{"type": "Building"}],
    5495           20 :                 "notification": {"endpoint": {"uri": "http://client.example.org/cb"}},
    5496           20 :                 "temporalQ": tq
    5497              :             })
    5498           20 :             .to_string();
    5499           20 :             let req = Request::post("/ngsi-ld/v1/subscriptions")
    5500           20 :                 .header("Content-Type", "application/json")
    5501           20 :                 .header("Content-Length", body.len())
    5502           20 :                 .body(Body::from(body))
    5503           20 :                 .expect("req");
    5504           20 :             app.oneshot(req).await.expect("resp").status()
    5505           20 :         }
    5506            4 :         assert_eq!(
    5507            4 :             post_sub(json!({
    5508            4 :                 "timerel": "after",
    5509            4 :                 "timeAt": "2020-06-01T22:07:00Z",
    5510            4 :                 "timeproperty": "createdAt"
    5511            4 :             }))
    5512            4 :             .await,
    5513              :             StatusCode::CREATED,
    5514              :             "official fixture shape stays creatable"
    5515              :         );
    5516            4 :         assert_eq!(
    5517            4 :             post_sub(json!({"timerel": "after"})).await,
    5518              :             StatusCode::BAD_REQUEST,
    5519              :             "timeAt is cardinality 1"
    5520              :         );
    5521            4 :         assert_eq!(
    5522            4 :             post_sub(json!({"timeAt": "2020-06-01T22:07:00Z"})).await,
    5523              :             StatusCode::BAD_REQUEST,
    5524              :             "timerel is cardinality 1"
    5525              :         );
    5526            4 :         assert_eq!(
    5527            4 :             post_sub(json!({"timerel": "bogus", "timeAt": "2020-06-01T22:07:00Z"})).await,
    5528              :             StatusCode::BAD_REQUEST
    5529              :         );
    5530            4 :         assert_eq!(
    5531            4 :             post_sub(json!("after")).await,
    5532            4 :             StatusCode::BAD_REQUEST,
    5533            4 :             "temporalQ must be an object"
    5534            4 :         );
    5535            4 :     }
    5536              : }
    5537              : 
    5538              : #[cfg(test)]
    5539              : mod clause_5_2_22 {
    5540              :     use super::*;
    5541              :     use axum::body::Body;
    5542              :     use axum::http::{Request, StatusCode};
    5543              :     use serde_json::json;
    5544              :     use tower::ServiceExt;
    5545              : 
    5546           52 :     async fn post(path: &str, doc: serde_json::Value, tenant: &str) -> StatusCode {
    5547           52 :         let app = router(AppState::new(tenant.into()));
    5548           52 :         let body = doc.to_string();
    5549           52 :         let req = Request::post(format!("/ngsi-ld/v1/{path}"))
    5550           52 :             .header("Content-Type", "application/json")
    5551           52 :             .header("Content-Length", body.len())
    5552           52 :             .body(Body::from(body))
    5553           52 :             .expect("req");
    5554           52 :         app.oneshot(req).await.expect("resp").status()
    5555           52 :     }
    5556              : 
    5557              :     /// Table 5.2.22-1: key AND value are Strings, both cardinality 1 —
    5558              :     /// enforced on the notification endpoint's receiverInfo/notifierInfo.
    5559              :     #[tokio::test]
    5560            4 :     async fn endpoint_info_values_must_be_strings() {
    5561           28 :         let sub = |info_key: &str, entries: serde_json::Value| {
    5562           28 :             let mut d = json!({
    5563           28 :                 "type": "Subscription",
    5564           28 :                 "entities": [{"type": "Building"}],
    5565           28 :                 "notification": {"endpoint": {"uri": "http://client.example.org/cb"}}
    5566              :             });
    5567           28 :             d["notification"]["endpoint"][info_key] = entries;
    5568           28 :             d
    5569           28 :         };
    5570            4 :         assert_eq!(
    5571            4 :             post(
    5572            4 :                 "subscriptions",
    5573            4 :                 sub(
    5574            4 :                     "receiverInfo",
    5575            4 :                     json!([{"key": "Authorization", "value": "Bearer x"}])
    5576            4 :                 ),
    5577            4 :                 "t5222a"
    5578            4 :             )
    5579            4 :             .await,
    5580              :             StatusCode::CREATED,
    5581              :             "string values stay creatable"
    5582              :         );
    5583           20 :         for bad in [
    5584            4 :             json!(42),
    5585            4 :             json!({"a": 1}),
    5586            4 :             json!(["x"]),
    5587            4 :             json!(null),
    5588            4 :             json!(true),
    5589            4 :         ] {
    5590           20 :             assert_eq!(
    5591           20 :                 post(
    5592           20 :                     "subscriptions",
    5593           20 :                     sub("receiverInfo", json!([{"key": "K", "value": bad}])),
    5594           20 :                     "t5222a"
    5595           20 :                 )
    5596           20 :                 .await,
    5597            4 :                 StatusCode::BAD_REQUEST,
    5598            4 :                 "receiverInfo value {bad} is not a String"
    5599            4 :             );
    5600            4 :         }
    5601            4 :         assert_eq!(
    5602            4 :             post(
    5603            4 :                 "subscriptions",
    5604            4 :                 sub("notifierInfo", json!([{"key": "K", "value": 7}])),
    5605            4 :                 "t5222a"
    5606            4 :             )
    5607            4 :             .await,
    5608            4 :             StatusCode::BAD_REQUEST,
    5609            4 :             "notifierInfo value must be a String"
    5610            4 :         );
    5611            4 :     }
    5612              : 
    5613              :     /// Table 5.2.22-1 via 5.2.9 contextSourceInfo: every pair's value is a
    5614              :     /// String — a non-string value on a custom key is rejected at
    5615              :     /// registration, not at first forward.
    5616              :     #[tokio::test]
    5617            4 :     async fn context_source_info_values_must_be_strings() {
    5618           24 :         let csr = |info: serde_json::Value| {
    5619           24 :             json!({
    5620           24 :                 "type": "ContextSourceRegistration",
    5621           24 :                 "endpoint": "http://peer.example/ngsi-ld/v1",
    5622           24 :                 "information": [{"entities": [{"type": "Building"}]}],
    5623           24 :                 "contextSourceInfo": info
    5624              :             })
    5625           24 :         };
    5626            4 :         assert_eq!(
    5627            4 :             post(
    5628            4 :                 "csourceRegistrations",
    5629            4 :                 csr(json!([{"key": "X-Auth-Token", "value": "abc"}])),
    5630            4 :                 "t5222b"
    5631            4 :             )
    5632            4 :             .await,
    5633              :             StatusCode::CREATED,
    5634              :             "string values stay registrable"
    5635              :         );
    5636           16 :         for bad in [json!(123), json!(["a"]), json!({"v": 1}), json!(null)] {
    5637           16 :             assert_eq!(
    5638           16 :                 post(
    5639           16 :                     "csourceRegistrations",
    5640           16 :                     csr(json!([{"key": "X-Custom", "value": bad}])),
    5641           16 :                     "t5222b"
    5642           16 :                 )
    5643           16 :                 .await,
    5644            4 :                 StatusCode::BAD_REQUEST,
    5645            4 :                 "contextSourceInfo value {bad} is not a String"
    5646            4 :             );
    5647            4 :         }
    5648            4 :         assert_eq!(
    5649            4 :             post(
    5650            4 :                 "csourceRegistrations",
    5651            4 :                 csr(json!([{"key": 5, "value": "v"}])),
    5652            4 :                 "t5222b"
    5653            4 :             )
    5654            4 :             .await,
    5655            4 :             StatusCode::BAD_REQUEST,
    5656            4 :             "key must be a String"
    5657            4 :         );
    5658            4 :     }
    5659              : }
    5660              : 
    5661              : #[cfg(test)]
    5662              : mod clause_5_2_44 {
    5663              :     use super::*;
    5664              :     use axum::body::Body;
    5665              :     use axum::http::{Request, StatusCode};
    5666              :     use http_body_util::BodyExt;
    5667              :     use serde_json::json;
    5668              :     use tower::ServiceExt;
    5669              : 
    5670              :     /// Table 5.2.44-1: aggrParams carries aggrMethods (comma separated list
    5671              :     /// of strings — string and string-array spellings) + aggrPeriodDuration,
    5672              :     /// honoured on the temporal operation when aggregatedValues is requested.
    5673              :     #[tokio::test]
    5674            4 :     async fn aggregation_params_members() {
    5675            4 :         let app = router(AppState::new("t5244".into()));
    5676           24 :         let send = |doc: serde_json::Value, qs: &'static str| {
    5677           24 :             let app = app.clone();
    5678           24 :             async move {
    5679           24 :                 let body = doc.to_string();
    5680           24 :                 let req = Request::post(format!("/ngsi-ld/v1/temporal/entityOperations/query{qs}"))
    5681           24 :                     .header("Content-Type", "application/json")
    5682           24 :                     .header("Content-Length", body.len())
    5683           24 :                     .body(Body::from(body))
    5684           24 :                     .expect("req");
    5685           24 :                 let resp = app.oneshot(req).await.expect("resp");
    5686           24 :                 let status = resp.status();
    5687           24 :                 let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    5688           24 :                 (status, String::from_utf8_lossy(&bytes).into_owned())
    5689           24 :             }
    5690           24 :         };
    5691           24 :         let with_ap = |ap: serde_json::Value| {
    5692           24 :             json!({"type": "Query", "entities": [{"type": "Vehicle"}],
    5693           24 :                 "temporalQ": {"timerel": "after", "timeAt": "2020-01-01T00:00:00Z"},
    5694           24 :                 "aggrParams": ap})
    5695           24 :         };
    5696            4 :         let (st, body) = send(
    5697            4 :             with_ap(json!({"aggrMethods": ["avg"], "aggrPeriodDuration": "PT1H"})),
    5698            4 :             "?format=aggregatedValues",
    5699            4 :         )
    5700            4 :         .await;
    5701            4 :         assert_eq!(st, StatusCode::OK, "array spelling honoured: {body}");
    5702            4 :         assert!(!body.contains("BadRequestData"), "{body}");
    5703            4 :         let (st, _) = send(
    5704            4 :             with_ap(json!({"aggrMethods": "avg,max"})),
    5705            4 :             "?format=aggregatedValues",
    5706            4 :         )
    5707            4 :         .await;
    5708            4 :         assert_eq!(st, StatusCode::OK, "string spelling honoured");
    5709            4 :         let (st, body) = send(
    5710            4 :             with_ap(json!({"aggrMethods": ["bogus"]})),
    5711            4 :             "?format=aggregatedValues",
    5712            4 :         )
    5713            4 :         .await;
    5714            4 :         assert_eq!(st, StatusCode::BAD_REQUEST);
    5715            4 :         assert!(body.contains("aggrMethods"), "{body}");
    5716            4 :         let (st, body) = send(
    5717            4 :             with_ap(json!({"aggrMethods": ["avg"], "aggrPeriodDuration": "bogus"})),
    5718            4 :             "?format=aggregatedValues",
    5719            4 :         )
    5720            4 :         .await;
    5721            4 :         assert_eq!(st, StatusCode::BAD_REQUEST);
    5722            4 :         assert!(body.contains("aggrPeriodDuration"), "{body}");
    5723            4 :         let (st, _) = send(
    5724            4 :             with_ap(json!({"aggrMethods": 42})),
    5725            4 :             "?format=aggregatedValues",
    5726            4 :         )
    5727            4 :         .await;
    5728            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "aggrMethods wrong JSON type");
    5729            4 :         let (st, _) = send(with_ap(json!("avg")), "?format=aggregatedValues").await;
    5730            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "aggrParams must be an object");
    5731            4 :     }
    5732              : }
    5733              : 
    5734              : #[cfg(test)]
    5735              : mod clause_5_2_43 {
    5736              :     use super::*;
    5737              :     use axum::body::Body;
    5738              :     use axum::http::{Request, StatusCode};
    5739              :     use http_body_util::BodyExt;
    5740              :     use serde_json::json;
    5741              :     use tower::ServiceExt;
    5742              : 
    5743           36 :     async fn send(
    5744           36 :         app: &axum::Router,
    5745           36 :         method: &str,
    5746           36 :         path: &str,
    5747           36 :         doc: Option<serde_json::Value>,
    5748           36 :     ) -> (StatusCode, String) {
    5749           36 :         let mut b = Request::builder()
    5750           36 :             .method(method)
    5751           36 :             .uri(format!("/ngsi-ld/v1/{path}"));
    5752           36 :         let body = match doc {
    5753           32 :             Some(d) => {
    5754           32 :                 let s = d.to_string();
    5755           32 :                 b = b
    5756           32 :                     .header("Content-Type", "application/json")
    5757           32 :                     .header("Content-Length", s.len());
    5758           32 :                 Body::from(s)
    5759              :             }
    5760            4 :             None => Body::empty(),
    5761              :         };
    5762           36 :         let resp = app
    5763           36 :             .clone()
    5764           36 :             .oneshot(b.body(body).expect("req"))
    5765           36 :             .await
    5766           36 :             .expect("resp");
    5767           36 :         let status = resp.status();
    5768           36 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    5769           36 :         (status, String::from_utf8_lossy(&bytes).into_owned())
    5770           36 :     }
    5771              : 
    5772              :     /// Table 5.2.43-1: orderBy maps to the 4.23 keys, coordinates (JSON
    5773              :     /// array, mandatory for dist ordering) + geometry (default Point) form
    5774              :     /// the reference geometry; collation is rejected loudly while only
    5775              :     /// codepoint order is offered (4.23.1 named gap).
    5776              :     #[tokio::test]
    5777            4 :     async fn ordering_params_members() {
    5778            4 :         let app = router(AppState::new("t5243".into()));
    5779            8 :         for (id, lon, lat) in [
    5780            4 :             ("urn:ngsi-ld:Vehicle:near", 8.01, 40.01),
    5781            4 :             ("urn:ngsi-ld:Vehicle:far", 10.0, 45.0),
    5782            4 :         ] {
    5783            8 :             let (st, body) = send(
    5784            8 :                 &app,
    5785            8 :                 "POST",
    5786            8 :                 "entities",
    5787            8 :                 Some(json!({"id": id, "type": "Vehicle",
    5788            8 :                     "location": {"type": "GeoProperty",
    5789            8 :                         "value": {"type": "Point", "coordinates": [lon, lat]}}})),
    5790              :             )
    5791            8 :             .await;
    5792            8 :             assert_eq!(st, StatusCode::CREATED, "{body}");
    5793              :         }
    5794            4 :         let q = "entityOperations/query";
    5795           24 :         let with_ordering = |o: serde_json::Value| json!({"type": "Query", "entities": [{"type": "Vehicle"}], "ordering": o});
    5796            4 :         let (st, body) = send(
    5797            4 :             &app,
    5798            4 :             "POST",
    5799            4 :             q,
    5800            4 :             Some(with_ordering(json!({"orderBy": ["location;dist-asc"],
    5801            4 :                 "coordinates": [8, 40], "geometry": "Point"}))),
    5802              :         )
    5803            4 :         .await;
    5804            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    5805            4 :         let near = body.find("urn:ngsi-ld:Vehicle:near").expect("near in body");
    5806            4 :         let far = body.find("urn:ngsi-ld:Vehicle:far").expect("far in body");
    5807            4 :         assert!(near < far, "dist-asc must order near before far: {body}");
    5808            4 :         let (st, _) = send(
    5809            4 :             &app,
    5810            4 :             "POST",
    5811            4 :             q,
    5812            4 :             Some(with_ordering(json!({"orderBy": ["location;dist-asc"]}))),
    5813              :         )
    5814            4 :         .await;
    5815            4 :         assert_eq!(
    5816              :             st,
    5817              :             StatusCode::BAD_REQUEST,
    5818              :             "dist ordering without coordinates"
    5819              :         );
    5820            4 :         let (st, _) = send(
    5821            4 :             &app,
    5822            4 :             "POST",
    5823            4 :             q,
    5824            4 :             Some(with_ordering(json!({"orderBy": ["location;dist-asc"],
    5825            4 :                 "coordinates": "8,40"}))),
    5826              :         )
    5827            4 :         .await;
    5828            4 :         assert_eq!(
    5829              :             st,
    5830              :             StatusCode::BAD_REQUEST,
    5831              :             "coordinates must be a JSON array"
    5832              :         );
    5833            4 :         let (st, _) = send(
    5834            4 :             &app,
    5835            4 :             "POST",
    5836            4 :             q,
    5837            4 :             Some(with_ordering(json!({"orderBy": ["id;asc"], "geometry": 5}))),
    5838              :         )
    5839            4 :         .await;
    5840            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "geometry must be a string");
    5841              :         // 4.23.3 EXAMPLE 7: a named ICU collation is honoured (200), and a
    5842              :         // non-string member stays a loud 400
    5843            4 :         let (st, body) = send(
    5844            4 :             &app,
    5845            4 :             "POST",
    5846            4 :             q,
    5847            4 :             Some(with_ordering(json!({"orderBy": ["id;asc"],
    5848            4 :                 "collation": "de-u-co-phonebk"}))),
    5849              :         )
    5850            4 :         .await;
    5851            4 :         assert_eq!(st, StatusCode::OK, "named collation is honoured: {body}");
    5852            4 :         let (st, body) = send(
    5853            4 :             &app,
    5854            4 :             "POST",
    5855            4 :             q,
    5856            4 :             Some(with_ordering(
    5857            4 :                 json!({"orderBy": ["id;asc"], "collation": 5}),
    5858            4 :             )),
    5859              :         )
    5860            4 :         .await;
    5861            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "{body}");
    5862            4 :         assert!(body.contains("collation"), "{body}");
    5863              :         // GET twin: orderGeometry is a legal query parameter
    5864            4 :         let (st, body) = send(
    5865            4 :             &app,
    5866            4 :             "GET",
    5867            4 :             "entities?type=Vehicle&orderBy=location;dist-asc&orderFrom=[8,40]&orderGeometry=Point",
    5868            4 :             None,
    5869              :         )
    5870            4 :         .await;
    5871            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    5872            4 :     }
    5873              : }
    5874              : 
    5875              : #[cfg(test)]
    5876              : mod clause_5_2_34 {
    5877              :     use super::*;
    5878              :     use axum::body::Body;
    5879              :     use axum::http::{Request, StatusCode};
    5880              :     use serde_json::json;
    5881              :     use tower::ServiceExt;
    5882              : 
    5883           32 :     async fn post_csr(management: serde_json::Value) -> StatusCode {
    5884           32 :         let app = router(AppState::new("t5234".into()));
    5885           32 :         let body = json!({
    5886           32 :             "type": "ContextSourceRegistration",
    5887           32 :             "endpoint": "http://peer.example/ngsi-ld/v1",
    5888           32 :             "information": [{"entities": [{"type": "Building"}]}],
    5889           32 :             "management": management
    5890              :         })
    5891           32 :         .to_string();
    5892           32 :         let req = Request::post("/ngsi-ld/v1/csourceRegistrations")
    5893           32 :             .header("Content-Type", "application/json")
    5894           32 :             .header("Content-Length", body.len())
    5895           32 :             .body(Body::from(body))
    5896           32 :             .expect("req");
    5897           32 :         app.oneshot(req).await.expect("resp").status()
    5898           32 :     }
    5899              : 
    5900              :     /// Table 5.2.34-1: cacheDuration is an ISO 8601 duration, cooldown and
    5901              :     /// timeout are numbers greater than 0, localOnly is a boolean, and the
    5902              :     /// member itself is an object.
    5903              :     #[tokio::test]
    5904            4 :     async fn registration_management_info_value_spaces() {
    5905            4 :         assert_eq!(
    5906            4 :             post_csr(json!({"cacheDuration": "PT5M", "cooldown": 500,
    5907            4 :                 "timeout": 3000, "localOnly": true}))
    5908            4 :             .await,
    5909              :             StatusCode::CREATED,
    5910              :             "conformant management info stays registrable"
    5911              :         );
    5912           28 :         for (label, m) in [
    5913            4 :             ("non-object", json!("yes")),
    5914            4 :             ("bad cacheDuration", json!({"cacheDuration": "bogus"})),
    5915            4 :             ("cacheDuration wrong type", json!({"cacheDuration": 300})),
    5916            4 :             ("cooldown zero", json!({"cooldown": 0})),
    5917            4 :             ("timeout negative", json!({"timeout": -5})),
    5918            4 :             ("timeout wrong type", json!({"timeout": "3000"})),
    5919            4 :             ("localOnly wrong type", json!({"localOnly": "yes"})),
    5920            4 :         ] {
    5921           28 :             assert_eq!(
    5922           28 :                 post_csr(m).await,
    5923            4 :                 StatusCode::BAD_REQUEST,
    5924            4 :                 "management {label}"
    5925            4 :             );
    5926            4 :         }
    5927            4 :     }
    5928              : }
    5929              : 
    5930              : #[cfg(test)]
    5931              : mod clause_5_2_33 {
    5932              :     use super::*;
    5933              :     use axum::body::Body;
    5934              :     use axum::http::{Request, StatusCode};
    5935              :     use http_body_util::BodyExt;
    5936              :     use serde_json::json;
    5937              :     use tower::ServiceExt;
    5938              : 
    5939           40 :     async fn send(app: &axum::Router, path: &str, doc: &serde_json::Value) -> (StatusCode, String) {
    5940           40 :         let body = doc.to_string();
    5941           40 :         let req = Request::post(format!("/ngsi-ld/v1/{path}"))
    5942           40 :             .header("Content-Type", "application/json")
    5943           40 :             .header("Content-Length", body.len())
    5944           40 :             .body(Body::from(body))
    5945           40 :             .expect("req");
    5946           40 :         let resp = app.clone().oneshot(req).await.expect("resp");
    5947           40 :         let status = resp.status();
    5948           40 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    5949           40 :         (status, String::from_utf8_lossy(&bytes).into_owned())
    5950           40 :     }
    5951              : 
    5952              :     /// Table 5.2.33-1: id is "String or String[]" of valid URIs, type is
    5953              :     /// mandatory, and "id takes precedence over idPattern".
    5954              :     #[tokio::test]
    5955            4 :     async fn entity_selector_id_forms_and_precedence() {
    5956            4 :         let app = router(AppState::new("t5233a".into()));
    5957           12 :         for id in [
    5958            4 :             "urn:ngsi-ld:Vehicle:A1",
    5959            4 :             "urn:ngsi-ld:Vehicle:A2",
    5960            4 :             "urn:ngsi-ld:Vehicle:B1",
    5961            4 :         ] {
    5962           12 :             let (st, body) = send(
    5963           12 :                 &app,
    5964           12 :                 "entities",
    5965           12 :                 &json!({"id": id, "type": "Vehicle",
    5966           12 :                         "speed": {"type": "Property", "value": 1}}),
    5967              :             )
    5968           12 :             .await;
    5969           12 :             assert_eq!(st, StatusCode::CREATED, "{body}");
    5970              :         }
    5971            4 :         let q = "entityOperations/query";
    5972           20 :         let sel = |e: serde_json::Value| json!({"type": "Query", "entities": [e]});
    5973            4 :         let (st, body) = send(
    5974            4 :             &app,
    5975            4 :             q,
    5976            4 :             &sel(json!({"type": "Vehicle",
    5977            4 :                 "id": ["urn:ngsi-ld:Vehicle:A1", "urn:ngsi-ld:Vehicle:A2"]})),
    5978              :         )
    5979            4 :         .await;
    5980            4 :         assert_eq!(st, StatusCode::OK, "id array form: {body}");
    5981            4 :         assert!(body.contains("urn:ngsi-ld:Vehicle:A1"), "{body}");
    5982            4 :         assert!(body.contains("urn:ngsi-ld:Vehicle:A2"), "{body}");
    5983            4 :         assert!(!body.contains("urn:ngsi-ld:Vehicle:B1"), "{body}");
    5984            4 :         let (st, body) = send(
    5985            4 :             &app,
    5986            4 :             q,
    5987            4 :             &sel(json!({"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:A1",
    5988            4 :                 "idPattern": "^urn:ngsi-ld:Vehicle:B.*$"})),
    5989              :         )
    5990            4 :         .await;
    5991            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    5992            4 :         assert!(
    5993            4 :             body.contains("urn:ngsi-ld:Vehicle:A1"),
    5994              :             "id takes precedence over idPattern: {body}"
    5995              :         );
    5996            4 :         let (st, _) = send(&app, q, &sel(json!({"id": "urn:ngsi-ld:Vehicle:A1"}))).await;
    5997            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "type is mandatory (5.2.33)");
    5998            4 :         let (st, _) = send(
    5999            4 :             &app,
    6000            4 :             q,
    6001            4 :             &sel(json!({"type": "Vehicle", "id": ["urn:ngsi-ld:Vehicle:A1", 5]})),
    6002              :         )
    6003            4 :         .await;
    6004            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "id entries must be strings");
    6005            4 :         let (st, _) = send(&app, q, &sel(json!({"type": "Vehicle", "id": "not a uri"}))).await;
    6006            4 :         assert_eq!(st, StatusCode::BAD_REQUEST, "id must be a valid URI");
    6007            4 :     }
    6008              : 
    6009              :     /// 5.2.33 in Subscription.entities: the String[] id form is accepted at
    6010              :     /// creation.
    6011              :     #[tokio::test]
    6012            4 :     async fn subscription_selector_id_array() {
    6013            4 :         let app = router(AppState::new("t5233b".into()));
    6014            8 :         let sub = |e: serde_json::Value| {
    6015            8 :             json!({"type": "Subscription", "entities": [e],
    6016            8 :                 "notification": {"endpoint": {"uri": "http://client.example.org/cb"}}})
    6017            8 :         };
    6018            4 :         let (st, body) = send(
    6019            4 :             &app,
    6020            4 :             "subscriptions",
    6021            4 :             &sub(json!({"type": "Building",
    6022            4 :                 "id": ["urn:ngsi-ld:Building:a", "urn:ngsi-ld:Building:b"]})),
    6023              :         )
    6024            4 :         .await;
    6025            4 :         assert_eq!(st, StatusCode::CREATED, "{body}");
    6026            4 :         let (st, _) = send(
    6027            4 :             &app,
    6028            4 :             "subscriptions",
    6029            4 :             &sub(json!({"type": "Building", "id": ["urn:ngsi-ld:Building:a", 5]})),
    6030              :         )
    6031            4 :         .await;
    6032            4 :         assert_eq!(st, StatusCode::BAD_REQUEST);
    6033            4 :     }
    6034              : }
    6035              : 
    6036              : #[cfg(test)]
    6037              : mod clause_5_2_23 {
    6038              :     use super::*;
    6039              :     use axum::body::Body;
    6040              :     use axum::http::{Request, StatusCode};
    6041              :     use http_body_util::BodyExt;
    6042              :     use serde_json::json;
    6043              :     use tower::ServiceExt;
    6044              : 
    6045          116 :     async fn send(
    6046          116 :         app: &axum::Router,
    6047          116 :         method: &str,
    6048          116 :         path: &str,
    6049          116 :         doc: &serde_json::Value,
    6050          116 :     ) -> (StatusCode, String) {
    6051          116 :         let body = doc.to_string();
    6052          116 :         let req = Request::builder()
    6053          116 :             .method(method)
    6054          116 :             .uri(format!("/ngsi-ld/v1/{path}"))
    6055          116 :             .header("Content-Type", "application/json")
    6056          116 :             .header("Content-Length", body.len())
    6057          116 :             .body(Body::from(body))
    6058          116 :             .expect("req");
    6059          116 :         let resp = app.clone().oneshot(req).await.expect("resp");
    6060          116 :         let status = resp.status();
    6061          116 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    6062          116 :         (status, String::from_utf8_lossy(&bytes).into_owned())
    6063          116 :     }
    6064              : 
    6065           80 :     fn query(extra: serde_json::Value) -> serde_json::Value {
    6066           80 :         let mut d = json!({"type": "Query", "entities": [{"type": "Vehicle"}]});
    6067           84 :         for (k, v) in extra.as_object().expect("obj") {
    6068           84 :             d[k] = v.clone();
    6069           84 :         }
    6070           80 :         d
    6071           80 :     }
    6072              : 
    6073              :     /// Table 5.2.23-1: member value spaces — empty arrays are not allowed
    6074              :     /// (entities/attrs/pick/omit), string members must be strings, joinLevel
    6075              :     /// is a positive integer, geoQ/ordering are objects, and
    6076              :     /// temporalQ/aggrParams are only allowed on the temporal operation.
    6077              :     #[tokio::test]
    6078            4 :     async fn query_body_member_value_spaces() {
    6079            4 :         let app = router(AppState::new("t5223a".into()));
    6080            4 :         let q = "entityOperations/query";
    6081            4 :         let (st, body) = send(&app, "POST", q, &query(json!({}))).await;
    6082            4 :         assert_eq!(st, StatusCode::OK, "control: {body}");
    6083            4 :         assert!(!body.contains("BadRequestData"), "{body}");
    6084           72 :         for (label, doc) in [
    6085            4 :             ("type not Query", json!({"type": "NotQuery"})),
    6086            4 :             ("entities empty", json!({"type": "Query", "entities": []})),
    6087            4 :             (
    6088            4 :                 "entities not array",
    6089            4 :                 query(json!({"entities": "Vehicle"})).clone(),
    6090            4 :             ),
    6091            4 :             ("attrs empty", query(json!({"attrs": []}))),
    6092            4 :             ("attrs non-string entry", query(json!({"attrs": [7]}))),
    6093            4 :             ("pick empty", query(json!({"pick": []}))),
    6094            4 :             ("omit empty", query(json!({"omit": []}))),
    6095            4 :             ("q not a string", query(json!({"q": 42}))),
    6096            4 :             ("csf not a string", query(json!({"csf": 42}))),
    6097            4 :             (
    6098            4 :                 "datasetId not an array",
    6099            4 :                 query(json!({"datasetId": "urn:x"})),
    6100            4 :             ),
    6101            4 :             (
    6102            4 :                 "joinLevel zero",
    6103            4 :                 query(json!({"join": "inline", "joinLevel": 0})),
    6104            4 :             ),
    6105            4 :             (
    6106            4 :                 "joinLevel fractional",
    6107            4 :                 query(json!({"join": "inline", "joinLevel": 2.5})),
    6108            4 :             ),
    6109            4 :             ("geoQ not an object", query(json!({"geoQ": "near"}))),
    6110            4 :             ("ordering not an object", query(json!({"ordering": "asc"}))),
    6111            4 :             (
    6112            4 :                 "splitEntities not a boolean",
    6113            4 :                 query(json!({"splitEntities": "yes"})),
    6114            4 :             ),
    6115            4 :             (
    6116            4 :                 "entityMap not a boolean",
    6117            4 :                 query(json!({"entityMap": "yes"})),
    6118            4 :             ),
    6119            4 :             (
    6120            4 :                 "temporalQ only for the temporal operation",
    6121            4 :                 query(json!({"temporalQ": {"timerel": "after", "timeAt": "2020-01-01T00:00:00Z"}})),
    6122            4 :             ),
    6123            4 :             (
    6124            4 :                 "aggrParams only for the temporal operation",
    6125            4 :                 query(json!({"aggrParams": {"aggrMethods": "avg"}})),
    6126            4 :             ),
    6127            4 :         ] {
    6128           72 :             let (st, body) = send(&app, "POST", q, &doc).await;
    6129           72 :             assert_eq!(st, StatusCode::BAD_REQUEST, "{label}: {body}");
    6130              :         }
    6131              :         // entities selector: non-object entries violate 5.2.33 EntitySelector[]
    6132            4 :         let (st, _) = send(
    6133            4 :             &app,
    6134            4 :             "POST",
    6135            4 :             q,
    6136            4 :             &json!({"type": "Query", "entities": ["Vehicle"]}),
    6137              :         )
    6138            4 :         .await;
    6139            4 :         assert_eq!(
    6140            4 :             st,
    6141            4 :             StatusCode::BAD_REQUEST,
    6142            4 :             "entities entries must be objects"
    6143            4 :         );
    6144            4 :     }
    6145              : 
    6146              :     /// Table 5.2.23-1: q/pick/omit conveyed in the body are honoured exactly
    6147              :     /// like their 6.3.7 query-parameter twins.
    6148              :     #[tokio::test]
    6149            4 :     async fn query_body_members_are_honoured() {
    6150            4 :         let app = router(AppState::new("t5223b".into()));
    6151            8 :         for (id, speed) in [
    6152            4 :             ("urn:ngsi-ld:Vehicle:A1", 80),
    6153            4 :             ("urn:ngsi-ld:Vehicle:A2", 120),
    6154            4 :         ] {
    6155            8 :             let (st, body) = send(
    6156            8 :                 &app,
    6157            8 :                 "POST",
    6158            8 :                 "entities",
    6159            8 :                 &json!({"id": id, "type": "Vehicle",
    6160            8 :                         "speed": {"type": "Property", "value": speed},
    6161            8 :                         "brand": {"type": "Property", "value": "Mercedes"}}),
    6162              :             )
    6163            8 :             .await;
    6164            8 :             assert_eq!(st, StatusCode::CREATED, "{body}");
    6165              :         }
    6166            4 :         let q = "entityOperations/query";
    6167            4 :         let (st, body) = send(&app, "POST", q, &query(json!({"q": "speed>100"}))).await;
    6168            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    6169            4 :         assert!(body.contains("urn:ngsi-ld:Vehicle:A2"), "{body}");
    6170            4 :         assert!(
    6171            4 :             !body.contains("urn:ngsi-ld:Vehicle:A1"),
    6172              :             "q must filter: {body}"
    6173              :         );
    6174            4 :         let (st, body) = send(&app, "POST", q, &query(json!({"pick": ["speed"]}))).await;
    6175            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    6176            4 :         assert!(body.contains("speed"), "{body}");
    6177            4 :         assert!(!body.contains("brand"), "pick must project: {body}");
    6178            4 :         let (st, body) = send(&app, "POST", q, &query(json!({"omit": ["speed"]}))).await;
    6179            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    6180            4 :         assert!(body.contains("brand"), "{body}");
    6181            4 :         assert!(!body.contains("speed"), "omit must remove: {body}");
    6182            4 :     }
    6183              : 
    6184              :     /// Table 5.2.23-1 on the temporal operation (5.7.4): the entities
    6185              :     /// selector's id member selects, temporalQ is accepted, and containedBy
    6186              :     /// is "Only applicable for the Retrieve Entity and Query Entities
    6187              :     /// operations".
    6188              :     #[tokio::test]
    6189            4 :     async fn temporal_query_body_entity_selector() {
    6190            4 :         let app = router(AppState::new("t5223c".into()));
    6191            8 :         for id in ["urn:ngsi-ld:Vehicle:T1", "urn:ngsi-ld:Vehicle:T2"] {
    6192            8 :             let (st, body) = send(
    6193            8 :                 &app,
    6194            8 :                 "POST",
    6195            8 :                 "temporal/entities",
    6196            8 :                 &json!({"id": id, "type": "Vehicle",
    6197            8 :                         "speed": [{"type": "Property", "value": 1,
    6198            8 :                                    "observedAt": "2020-08-01T12:00:00Z"}]}),
    6199              :             )
    6200            8 :             .await;
    6201            8 :             assert!(st.is_success(), "{st} {body}");
    6202              :         }
    6203            4 :         let tq = json!({"timerel": "after", "timeAt": "2020-01-01T00:00:00Z"});
    6204            4 :         let doc = json!({"type": "Query",
    6205            4 :             "entities": [{"type": "Vehicle", "id": "urn:ngsi-ld:Vehicle:T1"}],
    6206            4 :             "temporalQ": tq});
    6207            4 :         let (st, body) = send(&app, "POST", "temporal/entityOperations/query", &doc).await;
    6208            4 :         assert_eq!(st, StatusCode::OK, "{body}");
    6209            4 :         assert!(body.contains("urn:ngsi-ld:Vehicle:T1"), "{body}");
    6210            4 :         assert!(
    6211            4 :             !body.contains("urn:ngsi-ld:Vehicle:T2"),
    6212              :             "entities id selector must narrow the temporal query: {body}"
    6213              :         );
    6214            4 :         let doc = json!({"type": "Query", "entities": [{"type": "Vehicle"}],
    6215            4 :             "temporalQ": tq, "containedBy": ["urn:ngsi-ld:Vehicle:T2"]});
    6216            4 :         let (st, _) = send(&app, "POST", "temporal/entityOperations/query", &doc).await;
    6217            4 :         assert_eq!(
    6218            4 :             st,
    6219            4 :             StatusCode::BAD_REQUEST,
    6220            4 :             "containedBy is not applicable to 5.7.4"
    6221            4 :         );
    6222            4 :     }
    6223              : }
    6224              : 
    6225              : #[cfg(test)]
    6226              : mod clause_6_3_11 {
    6227              :     use super::*;
    6228              :     use axum::body::Body;
    6229              :     use axum::http::{Request, StatusCode};
    6230              :     use axum::response::Response;
    6231              :     use http_body_util::BodyExt;
    6232              :     use tower::ServiceExt;
    6233              : 
    6234           16 :     async fn body_json(resp: Response) -> serde_json::Value {
    6235           16 :         let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    6236           16 :         serde_json::from_slice(&bytes).expect("json body")
    6237           16 :     }
    6238              : 
    6239              :     /// 6.3.11 Table 6.3.11-1: `expiresAt` is included in response payloads
    6240              :     /// only when `options=sysAttrs` — entity level, attribute level and
    6241              :     /// temporal instances alike (same gate as createdAt/modifiedAt).
    6242              :     #[tokio::test]
    6243            4 :     async fn clause_6_3_11_expires_at_gated_by_sysattrs() {
    6244            4 :         let mut st = AppState::new("antares-test".into());
    6245            4 :         crate::wire(&mut st).await;
    6246            4 :         let app = router(st);
    6247            4 :         let entity = serde_json::json!({
    6248            4 :             "id": "urn:ngsi-ld:Building:exp6311", "type": "Building",
    6249            4 :             "expiresAt": "2100-01-01T00:00:00Z",
    6250            4 :             "name": {"type": "Property", "value": "x",
    6251            4 :                      "observedAt": "2026-08-13T00:00:00Z",
    6252            4 :                      "expiresAt": "2100-01-01T00:00:00Z"}
    6253              :         });
    6254            4 :         let body = entity.to_string();
    6255            4 :         let resp = app
    6256            4 :             .clone()
    6257            4 :             .oneshot(
    6258            4 :                 Request::post("/ngsi-ld/v1/entities")
    6259            4 :                     .header("Content-Type", "application/json")
    6260            4 :                     .header("Content-Length", body.len())
    6261            4 :                     .body(Body::from(body))
    6262            4 :                     .expect("req"),
    6263            4 :             )
    6264            4 :             .await
    6265            4 :             .expect("resp");
    6266            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
    6267              : 
    6268            4 :         let plain = body_json(
    6269            4 :             app.clone()
    6270            4 :                 .oneshot(
    6271            4 :                     Request::get("/ngsi-ld/v1/entities/urn:ngsi-ld:Building:exp6311")
    6272            4 :                         .body(Body::empty())
    6273            4 :                         .expect("req"),
    6274            4 :                 )
    6275            4 :                 .await
    6276            4 :                 .expect("resp"),
    6277              :         )
    6278            4 :         .await;
    6279            4 :         assert!(
    6280            4 :             plain.get("expiresAt").is_none(),
    6281              :             "entity expiresAt must be sysAttrs-gated (6.3.11): {plain}"
    6282              :         );
    6283            4 :         assert!(
    6284            4 :             plain["name"].get("expiresAt").is_none(),
    6285              :             "attribute expiresAt must be sysAttrs-gated (6.3.11): {plain}"
    6286              :         );
    6287              : 
    6288            4 :         let sys = body_json(
    6289            4 :             app.clone()
    6290            4 :                 .oneshot(
    6291            4 :                     Request::get(
    6292            4 :                         "/ngsi-ld/v1/entities/urn:ngsi-ld:Building:exp6311?options=sysAttrs",
    6293            4 :                     )
    6294            4 :                     .body(Body::empty())
    6295            4 :                     .expect("req"),
    6296            4 :                 )
    6297            4 :                 .await
    6298            4 :                 .expect("resp"),
    6299              :         )
    6300            4 :         .await;
    6301            4 :         assert_eq!(sys["expiresAt"], "2100-01-01T00:00:00Z");
    6302            4 :         assert_eq!(sys["name"]["expiresAt"], "2100-01-01T00:00:00Z");
    6303              : 
    6304            8 :         let inst_of = |body: &serde_json::Value| -> serde_json::Value {
    6305            8 :             let a = &body["name"];
    6306            8 :             if a.is_array() {
    6307            8 :                 a[0].clone()
    6308              :             } else {
    6309            0 :                 a.clone()
    6310              :             }
    6311            8 :         };
    6312            4 :         let t_plain = body_json(
    6313            4 :             app.clone()
    6314            4 :                 .oneshot(
    6315            4 :                     Request::get(
    6316            4 :                         "/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:Building:exp6311?timerel=after&timeAt=2020-01-01T00:00:00Z",
    6317            4 :                     )
    6318            4 :                     .body(Body::empty())
    6319            4 :                     .expect("req"),
    6320            4 :                 )
    6321            4 :                 .await
    6322            4 :                 .expect("resp"),
    6323            4 :         ).await
    6324              :         ;
    6325            4 :         assert!(
    6326            4 :             inst_of(&t_plain).get("expiresAt").is_none(),
    6327              :             "temporal instance expiresAt must be sysAttrs-gated (6.3.11): {t_plain}"
    6328              :         );
    6329            4 :         let t_sys = body_json(
    6330            4 :             app.clone()
    6331            4 :                 .oneshot(
    6332            4 :                     Request::get(
    6333            4 :                         "/ngsi-ld/v1/temporal/entities/urn:ngsi-ld:Building:exp6311?timerel=after&timeAt=2020-01-01T00:00:00Z&options=sysAttrs",
    6334            4 :                     )
    6335            4 :                     .body(Body::empty())
    6336            4 :                     .expect("req"),
    6337            4 :                 )
    6338            4 :                 .await
    6339            4 :                 .expect("resp"),
    6340            4 :         ).await
    6341              :         ;
    6342            4 :         assert_eq!(inst_of(&t_sys)["expiresAt"], "2100-01-01T00:00:00Z");
    6343            4 :     }
    6344              : }
    6345              : 
    6346              : /// The programmatic egress override, not `ANTARES_EGRESS_ALLOW_PRIVATE`: a
    6347              : /// sibling test reading the environment while another rewrote it saw the
    6348              : /// policy missing and refused the loopback forward. An atomic store carries
    6349              : /// the same switch with no write for a reader to land in the middle of.
    6350              : #[cfg(test)]
    6351          124 : pub(crate) fn allow_private() {
    6352          124 :     antares_jsonld::allow_private_egress(true);
    6353          124 : }
        

Generated by: LCOV version 2.0-1