LCOV - code coverage report
Current view: top level - antares-api/src - contexts.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.8 % 708 685
Test Date: 2026-11-02 13:21:27 Functions: 64.4 % 225 145

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! /jsonldContexts management (5.13; resources 6.29/6.30).
       3              : //!
       4              : //! Three kinds (5.13.1): Hosted (client-added, served on demand),
       5              : //! Cached (externally-fetched, metadata only), ImplicitlyCreated (broker-made
       6              : //! wrappers for array @contexts on subscriptions, served on demand).
       7              : 
       8              : use crate::negotiate::*;
       9              : use crate::state::{now_iso, AppState};
      10              : use antares_jsonld::Context;
      11              : use antares_jsonld::Loader;
      12              : use antares_model::{NgsiError, TenantId};
      13              : use axum::body::Bytes;
      14              : use axum::extract::{Path, State};
      15              : use axum::http::{header, HeaderMap, StatusCode};
      16              : use axum::response::{IntoResponse, Response};
      17              : use serde_json::{json, Value};
      18              : use sha2::{Digest, Sha256};
      19              : use std::collections::HashMap;
      20              : use std::sync::Arc;
      21              : 
      22              : use crate::negotiate::CleanParams;
      23              : 
      24              : /// Base URL under which this broker publishes its own @context entries
      25              : /// (5.13.2.4 locally unique URI, 5.13.3.5 `URL`). The address is the
      26              : /// broker's, so it comes from ITS configuration — ANTARES_PUBLIC_URL, the
      27              : /// same value peers are handed as the 5.8.1.4 notification endpoint. The
      28              : /// request's `Host` header is client input and only the fallback for a
      29              : /// deployment that configures nothing.
      30          280 : pub(crate) fn base_url(headers: &HeaderMap) -> String {
      31          280 :     context_base(std::env::var("ANTARES_PUBLIC_URL").ok().as_deref(), headers)
      32          280 : }
      33              : 
      34              : /// The URL this broker is reached at, with no trailing slash, for a
      35              : /// document that names the broker's own resources by absolute URL (an
      36              : /// context URL): `ANTARES_PUBLIC_URL`, else `http://{Host}` of the
      37              : /// request, the same fallback as the JSON-LD context URL.
      38              : ///
      39              : /// ```
      40              : /// let headers = axum::http::HeaderMap::new();
      41              : /// assert!(antares_api::public_base(&headers).starts_with("http"));
      42              : /// ```
      43              : #[must_use]
      44            0 : pub fn public_base(headers: &HeaderMap) -> String {
      45            0 :     broker_base(std::env::var("ANTARES_PUBLIC_URL").ok().as_deref(), headers)
      46            0 : }
      47              : 
      48          296 : fn broker_base(configured: Option<&str>, headers: &HeaderMap) -> String {
      49          296 :     match configured.map(str::trim).filter(|u| !u.is_empty()) {
      50          224 :         Some(url) => url.trim_end_matches('/').to_owned(),
      51              :         None => {
      52           72 :             let host = headers
      53           72 :                 .get(header::HOST)
      54           72 :                 .and_then(|h| h.to_str().ok())
      55           72 :                 .unwrap_or("localhost:9090");
      56           72 :             format!("http://{host}")
      57              :         }
      58              :     }
      59          296 : }
      60              : 
      61          296 : fn context_base(configured: Option<&str>, headers: &HeaderMap) -> String {
      62          296 :     format!(
      63              :         "{}/ngsi-ld/v1/jsonldContexts",
      64          296 :         broker_base(configured, headers)
      65              :     )
      66          296 : }
      67              : 
      68              : /// Validate the `details` query param: absent | true | false (053_05 sends
      69              : /// `True`); anything else is 400 (052_04_02).
      70          522 : fn details_param(params: &HashMap<String, String>) -> Result<bool, NgsiError> {
      71          522 :     match params.get("details").map(|s| s.to_ascii_lowercase()) {
      72          228 :         None => Ok(false),
      73          294 :         Some(v) if v == "true" => Ok(true),
      74           70 :         Some(v) if v == "false" => Ok(false),
      75           14 :         Some(v) => Err(NgsiError::BadRequestData(format!(
      76           14 :             "invalid details value {v:?}"
      77           14 :         ))),
      78              :     }
      79          522 : }
      80              : 
      81          492 : fn reload_param(params: &HashMap<String, String>) -> Result<bool, NgsiError> {
      82          492 :     match params.get("reload").map(|s| s.to_ascii_lowercase()) {
      83          442 :         None => Ok(false),
      84           50 :         Some(v) if v == "true" => Ok(true),
      85           14 :         Some(v) if v == "false" => Ok(false),
      86            6 :         Some(v) => Err(NgsiError::BadRequestData(format!(
      87            6 :             "invalid reload value {v:?}"
      88            6 :         ))),
      89              :     }
      90          492 : }
      91              : 
      92              : /// Detailed metadata object (5.13.3.5).
      93          520 : fn details_obj(
      94          520 :     url: &str,
      95          520 :     local_id: &str,
      96          520 :     kind: &str,
      97          520 :     created_at: &str,
      98          520 :     usage: Option<&antares_jsonld::CtxUsage>,
      99          520 : ) -> Value {
     100          520 :     let mut o = json!({
     101          520 :         "URL": url,
     102          520 :         "localId": local_id,
     103          520 :         "kind": kind,
     104          520 :         "createdAt": created_at,
     105              :     });
     106              :     // numberOfHits/lastUsage only for kinds where the suite's arithmetic
     107              :     // expects them (Cached + ImplicitlyCreated; 053_04 vs 053_06)
     108          520 :     if let (false, Some(m)) = (kind == "Hosted", o.as_object_mut()) {
     109          430 :         let hits = usage.map(|u| u.hits).unwrap_or(0);
     110          430 :         m.insert("numberOfHits".into(), json!(hits));
     111          430 :         if let Some(u) = usage {
     112          426 :             m.insert("lastUsage".into(), json!(u.last_usage));
     113          426 :         }
     114           90 :     }
     115          520 :     o
     116          520 : }
     117              : 
     118              : /// A resolved @context entry, whichever backing it has.
     119              : enum CtxEntry {
     120              :     /// Hosted or ImplicitlyCreated store entry.
     121              :     Stored(Value),
     122              :     /// External URL known through the loader (kind Cached).
     123              :     Cached(antares_jsonld::CtxUsage),
     124              :     /// Built-in core context (undeletable).
     125              :     Core(String),
     126              : }
     127              : 
     128              : /// Usage view of a store row — the SHARED truth (per-instance loader
     129              : /// stats split-brain behind a load balancer; the usage_bump hook keeps the
     130              : /// row's counters current from every instance).
     131          950 : fn row_usage(doc: &Value) -> antares_jsonld::CtxUsage {
     132          950 :     let created_at = doc["createdAt"].as_str().unwrap_or_default().to_owned();
     133              :     antares_jsonld::CtxUsage {
     134          950 :         url: doc["url"].as_str().unwrap_or_default().to_owned(),
     135          950 :         local_id: doc["localId"].as_str().unwrap_or_default().to_owned(),
     136          950 :         last_usage: doc["lastUsage"]
     137          950 :             .as_str()
     138          950 :             .map(str::to_owned)
     139          950 :             .unwrap_or_else(|| created_at.clone()),
     140          950 :         hits: doc["numberOfHits"].as_u64().unwrap_or(0),
     141          950 :         created_at,
     142              :     }
     143          950 : }
     144              : 
     145              : /// Build the Cached-entry view of a store row.
     146          228 : fn cached_from_row(doc: &Value) -> CtxEntry {
     147          228 :     CtxEntry::Cached(row_usage(doc))
     148          228 : }
     149              : 
     150              : /// Ownership of a stored row (5.13.1, ADR-0021). Hosted and ImplicitlyCreated
     151              : /// rows hold term mappings authored through one tenant's requests and are
     152              : /// visible, servable and deletable only through that tenant; Cached rows are
     153              : /// copies of public documents the broker fetched and belong to no tenant.
     154              : /// Rows written before the owner member existed belong to the default tenant.
     155              : ///
     156              : /// The store answers the same question over its own `tenant_id`, so a row
     157              : /// another Tenant owns does not reach this check under Postgres. Both stay:
     158              : /// the day they disagree, the database is the one that refuses.
     159         1046 : fn row_visible(doc: &Value, tenant: &TenantId) -> bool {
     160         1046 :     antares_store::context_row_visible(doc, Some(tenant))
     161         1046 : }
     162              : 
     163              : /// 5.13.1: a Hosted or ImplicitlyCreated @context holds term mappings
     164              : /// authored through one Tenant's requests, so it is one of that Tenant's
     165              : /// documents and a purge of the Tenant takes it too. A Cached row is a copy
     166              : /// of a public document and belongs to no Tenant (see `row_visible`), so it
     167              : /// stays.
     168              : ///
     169              : /// The rows themselves also go with the store's own `purge_tenant`
     170              : /// (ADR-0021). What only this pass can do is release the loader's warm copy
     171              : /// and usage entry for each URL, which live in this process and would
     172              : /// otherwise keep serving a document whose row is gone.
     173           36 : pub async fn purge_tenant(st: &AppState, tenant: &TenantId) -> Result<(), NgsiError> {
     174           40 :     for row in st.store.context_list_meta(Some(tenant)).await? {
     175           26 :         if row.get("kind").and_then(Value::as_str) == Some("Cached") || !row_visible(&row, tenant) {
     176            8 :             continue;
     177           18 :         }
     178           18 :         let Some(local_id) = row.get("localId").and_then(Value::as_str) else {
     179            0 :             continue;
     180              :         };
     181           18 :         st.store.context_delete(Some(tenant), local_id).await?;
     182           18 :         if let Some(url) = row.get("url").and_then(Value::as_str) {
     183           18 :             st.loader.usage_remove(url).await;
     184            0 :         }
     185              :     }
     186           36 :     Ok(())
     187           36 : }
     188              : 
     189              : /// Most ImplicitlyCreated @contexts one Tenant may hold. A read with an inline
     190              : /// @context writes one row per DISTINCT @context, so a caller who may only
     191              : /// read could otherwise grow the store without bound by varying it.
     192              : const MAX_IMPLICIT_CONTEXTS: usize = 1_000;
     193              : 
     194              : /// 6.3.6: an "application/json" response "shall include a Link to the
     195              : /// associated JSON-LD @context", and a Link carries a URL. An inline @context
     196              : /// (an object, or an array of more than one entry) has none, so the broker
     197              : /// hosts it as an ImplicitlyCreated @context (TS 104 175 13.1) and answers
     198              : /// with a copy of `ctx` whose source is that URL. The local id is
     199              : /// content-addressed per Tenant: the same inline @context, request after
     200              : /// request, is one row behind one URL. Past [`MAX_IMPLICIT_CONTEXTS`] the
     201              : /// response keeps the Core @context Link it had before.
     202          204 : pub(crate) async fn linkable(
     203          204 :     st: &AppState,
     204          204 :     tenant: &TenantId,
     205          204 :     headers: &HeaderMap,
     206          204 :     ctx: &Arc<Context>,
     207          204 :     accept: Accept,
     208          204 : ) -> ApiResult<Arc<Context>> {
     209          204 :     let inline = match &ctx.source {
     210           12 :         Value::Object(_) => true,
     211            0 :         Value::Array(a) => a.len() > 1 || a.first().is_some_and(|v| !v.is_string()),
     212          192 :         _ => false,
     213              :     };
     214          204 :     if accept != Accept::Json || !inline {
     215          194 :         return Ok(Arc::clone(ctx));
     216           10 :     }
     217           10 :     let body = serde_json::to_vec(&ctx.source)
     218           10 :         .map_err(|e| NgsiError::InternalError(format!("@context not serializable: {e}")))?;
     219           10 :     let mut h = Sha256::new();
     220           10 :     h.update(tenant.as_str().as_bytes());
     221           10 :     h.update([0]);
     222           10 :     h.update(&body);
     223          320 :     let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
     224           10 :     let local_id = format!("urn:ngsi-ld:Context:v{hex}");
     225           10 :     let url = match st.store.context_get(Some(tenant), &local_id).await? {
     226            2 :         Some(row) if row_visible(&row, tenant) => {
     227            2 :             row.get("url").and_then(Value::as_str).map(str::to_owned)
     228              :         }
     229            0 :         Some(_) => None,
     230              :         None => {
     231            8 :             let held = st
     232            8 :                 .store
     233            8 :                 .context_list_meta(Some(tenant))
     234            8 :                 .await?
     235            8 :                 .iter()
     236            8 :                 .filter(|r| {
     237            0 :                     r.get("kind").and_then(Value::as_str) == Some("ImplicitlyCreated")
     238            0 :                         && row_visible(r, tenant)
     239            0 :                 })
     240            8 :                 .count();
     241            8 :             if held >= MAX_IMPLICIT_CONTEXTS {
     242            0 :                 tracing::warn!(
     243            0 :                     tenant = tenant.as_str(),
     244              :                     "ImplicitlyCreated @context cap reached; Link names the Core @context"
     245              :                 );
     246            0 :                 None
     247              :             } else {
     248            8 :                 let url = format!("{}/{local_id}", base_url(headers));
     249            8 :                 st.store
     250            8 :                     .context_put(
     251            8 :                         Some(tenant),
     252            8 :                         &local_id,
     253            8 :                         json!({
     254            8 :                             "url": url,
     255            8 :                             "localId": local_id,
     256            8 :                             "kind": "ImplicitlyCreated",
     257            8 :                             "createdAt": now_iso(),
     258            8 :                             "owner": tenant.as_str(),
     259            8 :                             "body": {"@context": ctx.source.clone()},
     260            8 :                         }),
     261            8 :                     )
     262            8 :                     .await?;
     263            8 :                 st.loader
     264            8 :                     .put_local_for(tenant, url.clone(), ctx.source.clone())
     265            8 :                     .await;
     266            8 :                 Some(url)
     267              :             }
     268              :         }
     269              :     };
     270           10 :     Ok(match url {
     271           10 :         Some(url) => {
     272           10 :             let mut linked = Context::clone(ctx);
     273           10 :             linked.source = Value::String(url);
     274           10 :             Arc::new(linked)
     275              :         }
     276            0 :         None => Arc::clone(ctx),
     277              :     })
     278          204 : }
     279              : 
     280              : /// Resolve an id to the @context it names (5.13.4.4). Every probe is a keyed
     281              : /// lookup: a store failure is an error, never "not found" — answering 404 for
     282              : /// a hiccup would tell the client to add the @context a second time.
     283          774 : async fn find_entry(st: &AppState, tenant: &TenantId, id: &str) -> ApiResult<Option<CtxEntry>> {
     284          774 :     if let Some(doc) = st.store.context_get(Some(tenant), id).await? {
     285              :         // Cached rows are addressable by their deterministic localId too.
     286          374 :         if doc.get("kind").and_then(Value::as_str) == Some("Cached") {
     287           12 :             return Ok(Some(cached_from_row(&doc)));
     288          362 :         }
     289              :         // another tenant's row is as absent as one that never existed
     290          362 :         return Ok(row_visible(&doc, tenant).then_some(CtxEntry::Stored(doc)));
     291          400 :     }
     292              :     // Stored entries are also addressable by their full URL (5.13.2.4): the
     293              :     // row key IS the URL's trailing segment, so this is one keyed lookup and
     294              :     // the row's own url still has to match the id.
     295          400 :     if let Some((_, local_id)) = id.rsplit_once("/ngsi-ld/v1/jsonldContexts/") {
     296           20 :         if let Some(doc) = st.store.context_get(Some(tenant), local_id).await? {
     297            8 :             if doc.get("url").and_then(Value::as_str) == Some(id) && row_visible(&doc, tenant) {
     298            4 :                 return Ok(Some(CtxEntry::Stored(doc)));
     299            4 :             }
     300           12 :         }
     301          380 :     }
     302          396 :     if Loader::is_pinned_core(id) {
     303           60 :         return Ok(Some(CtxEntry::Core(id.to_owned())));
     304          336 :     }
     305              :     // Cached entries: the persisted row is the ONE existence truth (the
     306              :     // per-instance usage map split-brains behind a load balancer). The row id
     307              :     // is uuid5(url), so a URL-shaped id resolves in O(1).
     308          336 :     if id.starts_with("http://") || id.starts_with("https://") {
     309          240 :         let rid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, id.as_bytes()).to_string();
     310          240 :         if let Some(doc) = st.store.context_get(Some(tenant), &rid).await? {
     311          216 :             if doc.get("kind").and_then(Value::as_str) == Some("Cached") {
     312          216 :                 return Ok(Some(cached_from_row(&doc)));
     313            0 :             }
     314           24 :         }
     315           96 :     }
     316          120 :     Ok(None)
     317          774 : }
     318              : 
     319              : /// Whether the stored @contexts are shown: not through a view that hides a
     320              : /// name (a view showing every name hides no term, rule 12), and not to
     321              : /// a caller the engine narrows (`policy::NAME_READS`), since one may define a
     322              : /// term for an Attribute that caller may not see.
     323          364 : fn shows_stored(st: &AppState, narrowing: &crate::policy::Filter) -> bool {
     324          364 :     let _ = st;
     325          364 :     narrowing.is_empty()
     326          364 : }
     327              : 
     328              : // ---------- POST /jsonldContexts (5.13.2) ----------
     329              : 
     330              : /// 5.13.2.4 + 5.5.4: a JSON-LD local context is a string (IRI), an object of
     331              : /// term definitions, null, or an array of those — anything else is invalid
     332              : /// JSON-LD and rejected as BadRequestData.
     333          224 : fn valid_context_shape(v: &Value) -> bool {
     334          224 :     match v {
     335          194 :         Value::String(_) | Value::Object(_) | Value::Null => true,
     336           18 :         Value::Array(a) => a
     337           18 :             .iter()
     338           32 :             .all(|e| matches!(e, Value::String(_) | Value::Object(_) | Value::Null)),
     339           12 :         _ => false,
     340              :     }
     341          224 : }
     342              : 
     343              : /// 5.13.2.4 Add @context: store the client-supplied @context under a new
     344              : /// locally unique URI, flagged "Hosted"; the URI is returned (Location).
     345          242 : pub async fn add_context(
     346          242 :     State(st): State<AppState>,
     347          242 :     CleanParams(params): CleanParams,
     348          242 :     headers: HeaderMap,
     349          242 :     body: Bytes,
     350          242 : ) -> Response {
     351          242 :     let go = async {
     352          242 :         let tenant = tenant_from(&headers)?;
     353          242 :         check_params(&params, &["local"])?;
     354              :         // 6.3.5: an unsupported media type is a bare 415. An ABSENT
     355              :         // Content-Type is tolerated — the body is parsed as JSON, as on the
     356              :         // entity routes — but a header that is present and unreadable is not
     357              :         // absent, and `content_type` reports both as the empty string. The
     358              :         // presence of the header is what separates them.
     359          242 :         gate!(st, &tenant, &headers, "5.13.2").await?;
     360          242 :         let ct = content_type(&headers)?;
     361          242 :         if headers.contains_key(header::CONTENT_TYPE)
     362          236 :             && ct != "application/ld+json"
     363          230 :             && ct != "application/json"
     364              :         {
     365            4 :             return Err(ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE));
     366          238 :         }
     367          238 :         let value: Value = serde_json::from_slice(&body)
     368          238 :             .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
     369          234 :         let ctx_val = value.get("@context").cloned().ok_or_else(|| {
     370              :             // 050_02: a JSON object without @context is InvalidRequest
     371           10 :             NgsiError::InvalidRequest("body must carry an @context member".into())
     372           10 :         })?;
     373          224 :         if !valid_context_shape(&ctx_val) {
     374           18 :             return Err(NgsiError::BadRequestData("invalid JSON-LD @context value".into()).into());
     375          206 :         }
     376              :         // 5.13.2.4: "a locally unique identifier (URI)"
     377          206 :         let local_id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
     378          206 :         let url = format!("{}/{local_id}", base_url(&headers));
     379          206 :         let doc = json!({
     380          206 :             "url": url,
     381          206 :             "localId": local_id,
     382          206 :             "kind": "Hosted",
     383          206 :             "createdAt": now_iso(),
     384              :             // the adding tenant owns the entry (5.13.1 Hosted)
     385          206 :             "owner": tenant.as_str(),
     386          206 :             "body": {"@context": ctx_val.clone()},
     387              :         });
     388          206 :         st.store.context_put(Some(&tenant), &local_id, doc).await?;
     389              :         // stored FOR the adding Tenant (5.13.1 Hosted): 5.5.10 confines the
     390              :         // mappings to that Tenant's operations, so resolution is scoped the
     391              :         // same way the serve/list/delete paths above are
     392          206 :         st.loader.put_local_for(&tenant, url.clone(), ctx_val).await;
     393          206 :         let mut resp = (
     394          206 :             StatusCode::CREATED,
     395          206 :             [(
     396          206 :                 header::LOCATION,
     397          206 :                 format!("/ngsi-ld/v1/jsonldContexts/{local_id}"),
     398          206 :             )],
     399          206 :         )
     400          206 :             .into_response();
     401          206 :         echo_tenant(&tenant, &mut resp);
     402          206 :         Ok::<_, ApiError>(resp)
     403          242 :     };
     404          242 :     go.await.unwrap_or_else(|e| e.into_response())
     405          242 : }
     406              : 
     407              : // ---------- GET /jsonldContexts (5.13.3) ----------
     408              : 
     409              : /// 5.13.3.4 List @contexts: one URL (or metadata object, 5.13.3.5) per
     410              : /// stored @context matching the kind filter; no filter → all kinds.
     411          264 : pub async fn list_contexts(
     412          264 :     State(st): State<AppState>,
     413          264 :     CleanParams(params): CleanParams,
     414          264 :     headers: HeaderMap,
     415          264 : ) -> Response {
     416          264 :     let go = async {
     417          264 :         let tenant = tenant_from(&headers)?;
     418              :         // Table 6.29.3.2-1: details and kind are the only parameters of this
     419              :         // resource — it serves the whole list, so a pagination parameter would
     420              :         // be accepted and silently ignored.
     421          264 :         check_params(&params, &["kind", "details", "local"])?;
     422          248 :         let narrowing = gate!(st, &tenant, &headers, "5.13.3").await?;
     423              : 
     424          248 :         let details = details_param(&params)?;
     425          234 :         let kind_filter = params.get("kind");
     426          234 :         if let Some(k) = kind_filter {
     427          124 :             if !["Hosted", "Cached", "ImplicitlyCreated"].contains(&k.as_str()) {
     428           10 :                 return Err(NgsiError::BadRequestData(format!("invalid kind {k:?}")).into());
     429          114 :             }
     430          110 :         }
     431         1258 :         let keep = |k: &str| kind_filter.is_none_or(|f| f == k);
     432              :         // (url, localId, kind, createdAt, usage)
     433          224 :         let mut entries: Vec<(
     434          224 :             String,
     435          224 :             String,
     436          224 :             String,
     437          224 :             String,
     438          224 :             Option<antares_jsonld::CtxUsage>,
     439          224 :         )> = Vec::new();
     440              :         // a stored @context is the tenant's and may define
     441              :         // a term for an Attribute a view hides, so through a view that hides
     442              :         // a name the list is the core @context alone (the pinned entries
     443              :         // below). The same for a caller the engine narrows.
     444          224 :         let stored = if !shows_stored(&st, &narrowing) {
     445            4 :             Vec::new()
     446              :         } else {
     447          220 :             st.store.context_list_meta(Some(&tenant)).await?
     448              :         };
     449         1034 :         for c in stored {
     450         1034 :             let kind = c
     451         1034 :                 .get("kind")
     452         1034 :                 .and_then(Value::as_str)
     453         1034 :                 .unwrap_or("Hosted")
     454         1034 :                 .to_owned();
     455         1034 :             if !keep(&kind) || !row_visible(&c, &tenant) {
     456          374 :                 continue;
     457          660 :             }
     458          660 :             let url = c
     459          660 :                 .get("url")
     460          660 :                 .and_then(Value::as_str)
     461          660 :                 .unwrap_or_default()
     462          660 :                 .to_owned();
     463              :             // counters from the ROW (shared truth), never this instance's map
     464          660 :             let usage = Some(row_usage(&c));
     465          660 :             entries.push((
     466          660 :                 url,
     467          660 :                 c.get("localId")
     468          660 :                     .and_then(Value::as_str)
     469          660 :                     .unwrap_or_default()
     470          660 :                     .to_owned(),
     471          660 :                 kind,
     472          660 :                 c.get("createdAt")
     473          660 :                     .and_then(Value::as_str)
     474          660 :                     .unwrap_or_default()
     475          660 :                     .to_owned(),
     476          660 :                 usage,
     477          660 :             ));
     478              :         }
     479              :         // Cached entries come from the store rows walked above — the shared
     480              :         // truth every instance sees. Loader-only usage entries are NOT
     481              :         // listed (an entry another instance deleted must not resurface),
     482              :         // with one exception: pinned core contexts never get a row and stay
     483              :         // listable from local usage.
     484          224 :         if keep("Cached") {
     485          860 :             for u in st.loader.usage_list().await {
     486          860 :                 if !Loader::is_pinned_core(&u.url) {
     487          584 :                     continue;
     488          276 :                 }
     489          276 :                 entries.push((
     490          276 :                     u.url.clone(),
     491          276 :                     u.local_id.clone(),
     492          276 :                     "Cached".into(),
     493          276 :                     u.created_at.clone(),
     494          276 :                     Some(u),
     495          276 :                 ));
     496              :             }
     497           92 :         }
     498          224 :         let payload = if details {
     499              :             Value::Array(
     500           74 :                 entries
     501           74 :                     .iter()
     502          394 :                     .map(|(url, lid, kind, created, usage)| {
     503          394 :                         details_obj(url, lid, kind, created, usage.as_ref())
     504          394 :                     })
     505           74 :                     .collect(),
     506              :             )
     507              :         } else {
     508          542 :             Value::Array(entries.iter().map(|(url, ..)| json!(url)).collect())
     509              :         };
     510          224 :         let mut resp = (
     511          224 :             StatusCode::OK,
     512          224 :             [(header::CONTENT_TYPE, "application/json")],
     513          224 :             axum::Json(payload),
     514          224 :         )
     515          224 :             .into_response();
     516          224 :         echo_tenant(&tenant, &mut resp);
     517          224 :         Ok::<_, ApiError>(resp)
     518          264 :     };
     519          264 :     go.await.unwrap_or_else(|e| e.into_response())
     520          264 : }
     521              : 
     522              : // ---------- GET /jsonldContexts/{ctxId} (5.13.4) ----------
     523              : 
     524              : /// 5.13.4.4 Serve @context: full content for Hosted/ImplicitlyCreated,
     525              : /// OperationNotSupported for Cached, ResourceNotFound for unknown ids;
     526              : /// details=true serves metadata for all kinds.
     527          274 : pub async fn serve_context(
     528          274 :     State(st): State<AppState>,
     529          274 :     Path(id): Path<String>,
     530          274 :     CleanParams(params): CleanParams,
     531          274 :     headers: HeaderMap,
     532          274 : ) -> Response {
     533          274 :     let go = async {
     534          274 :         let tenant = tenant_from(&headers)?;
     535          274 :         check_params(&params, &["details", "local", "core"])?;
     536          274 :         let narrowing = gate!(st, &tenant, &headers, "5.13.4", ids: &[&id]).await?;
     537          274 :         let details = details_param(&params)?;
     538              :         // TS 104 175 13.4.4: `core` with `details` is a BadRequest, and so is
     539              :         // a `core` that names no NGSI-LD version this build has a core for
     540          274 :         let core = match params.get("core") {
     541            2 :             Some(_) if details => {
     542            2 :                 return Err(NgsiError::BadRequestData("core and details together".into()).into())
     543              :             }
     544           16 :             Some(v) => Some(antares_jsonld::core_context_url(v).ok_or_else(|| {
     545            8 :                 NgsiError::BadRequestData(format!("core {v:?} is not a known major.minor version"))
     546            8 :             })?),
     547          256 :             None => None,
     548              :         };
     549          264 :         let entry = match find_entry(&st, &tenant, &id).await? {
     550              :             // a stored @context the list hides from this caller is as absent
     551              :             // here, under a view and under a narrowing engine
     552          140 :             Some(CtxEntry::Stored(_)) if !shows_stored(&st, &narrowing) => None,
     553          260 :             entry => entry,
     554              :         }
     555          264 :         .ok_or_else(|| NgsiError::ResourceNotFound(format!("@context {id} not found")))?;
     556              :         // the user @context's location and the core one, for every kind: the
     557              :         // pair is two URLs, not the content a Cached entry never serves
     558          204 :         if let Some(core) = core {
     559            6 :             let user = match &entry {
     560            4 :                 CtxEntry::Stored(doc) => doc["url"].as_str().unwrap_or_default().to_owned(),
     561            0 :                 CtxEntry::Cached(u) => u.url.clone(),
     562            2 :                 CtxEntry::Core(url) => url.clone(),
     563              :             };
     564            6 :             let mut resp = (
     565            6 :                 StatusCode::OK,
     566            6 :                 [(header::CONTENT_TYPE, "application/json")],
     567            6 :                 axum::Json(json!([user, core])),
     568            6 :             )
     569            6 :                 .into_response();
     570            6 :             echo_tenant(&tenant, &mut resp);
     571            6 :             return Ok(resp);
     572          198 :         }
     573          198 :         let payload = match &entry {
     574          132 :             CtxEntry::Stored(doc) => {
     575          132 :                 let kind = doc["kind"].as_str().unwrap_or("Hosted");
     576          132 :                 let url = doc["url"].as_str().unwrap_or_default();
     577          132 :                 if details {
     578           62 :                     let usage = row_usage(doc);
     579           62 :                     details_obj(
     580           62 :                         url,
     581           62 :                         doc["localId"].as_str().unwrap_or_default(),
     582           62 :                         kind,
     583           62 :                         doc["createdAt"].as_str().unwrap_or_default(),
     584           62 :                         Some(&usage),
     585              :                     )
     586              :                 } else {
     587           70 :                     doc["body"].clone()
     588              :                 }
     589              :             }
     590           52 :             CtxEntry::Cached(u) => {
     591           52 :                 if !details {
     592              :                     // Cached entries are never served on demand (5.13.4.4)
     593            0 :                     return Err(NgsiError::OperationNotSupported(
     594            0 :                         "Cached @contexts are not served on demand (5.13.4)".into(),
     595            0 :                     )
     596            0 :                     .into());
     597           52 :                 }
     598           52 :                 details_obj(&u.url, &u.local_id, "Cached", &u.created_at, Some(u))
     599              :             }
     600           14 :             CtxEntry::Core(url) => {
     601           14 :                 if details {
     602           12 :                     let usage = st.loader.usage_get(url).await;
     603           12 :                     details_obj(url, url, "Cached", &st.started_at, usage.as_ref())
     604              :                 } else {
     605            2 :                     return Err(NgsiError::OperationNotSupported(
     606            2 :                         "Cached @contexts are not served on demand (5.13.4)".into(),
     607            2 :                     )
     608            2 :                     .into());
     609              :                 }
     610              :             }
     611              :         };
     612              :         // a serve counts as a hit for Cached/ImplicitlyCreated entries, after
     613              :         // the value shown in this response (053_06/053_08 arithmetic) —
     614              :         // EXCEPT broker-internal fetches (a fleet peer resolving this
     615              :         // @context through the LB): the resolving instance bumps the shared
     616              :         // row itself, a serve-side bump would double-count (053_08 fleet).
     617          196 :         let internal_fetch = headers.contains_key(antares_jsonld::INTERNAL_FETCH_HEADER);
     618          132 :         match &entry {
     619          132 :             CtxEntry::Stored(doc) if !internal_fetch && doc["kind"] == "ImplicitlyCreated" => {
     620           56 :                 if let Some(u) = doc["url"].as_str() {
     621           56 :                     let _ = st.loader.bump_url(Some(&tenant), u).await;
     622            0 :                 }
     623              :             }
     624           52 :             CtxEntry::Cached(u) if !internal_fetch => {
     625           52 :                 let _ = st.loader.bump_url(Some(&tenant), &u.url).await;
     626              :             }
     627           88 :             _ => {}
     628              :         }
     629          196 :         let mut resp = (
     630          196 :             StatusCode::OK,
     631          196 :             [(header::CONTENT_TYPE, "application/json")],
     632          196 :             axum::Json(payload),
     633          196 :         )
     634          196 :             .into_response();
     635          196 :         echo_tenant(&tenant, &mut resp);
     636          196 :         Ok::<_, ApiError>(resp)
     637          274 :     };
     638          274 :     go.await.unwrap_or_else(|e| e.into_response())
     639          274 : }
     640              : 
     641              : // ---------- DELETE /jsonldContexts/{ctxId} (5.13.5) ----------
     642              : 
     643              : /// 5.13.5.4 Delete and Reload @context: unknown id → ResourceNotFound;
     644              : /// reload=true re-downloads a Cached @context in place (failure →
     645              : /// LdContextNotAvailable, entry kept) and is BadRequestData for other
     646              : /// kinds; without reload the entry is removed.
     647          492 : pub async fn delete_context(
     648          492 :     State(st): State<AppState>,
     649          492 :     Path(id): Path<String>,
     650          492 :     CleanParams(params): CleanParams,
     651          492 :     headers: HeaderMap,
     652          492 : ) -> Response {
     653          492 :     let go = async {
     654          492 :         let tenant = tenant_from(&headers)?;
     655          492 :         check_params(&params, &["reload", "local"])?;
     656          492 :         gate!(st, &tenant, &headers, "5.13.5", ids: &[&id]).await?;
     657              : 
     658          492 :         let reload = reload_param(&params)?;
     659              :         // an unknown id is 404 before any reload bullet (5.13.5.4, 051_04_01)
     660          486 :         let Some(entry) = find_entry(&st, &tenant, &id).await? else {
     661           48 :             return Err(NgsiError::ResourceNotFound(format!("@context {id} not found")).into());
     662              :         };
     663              :         // A Cached copy (the core one included) belongs to no Tenant and
     664              :         // serves every one: only the default Tenant, the operator's, may
     665              :         // evict or refetch it: one Tenant could otherwise evict or
     666              :         // refetch the cache every other Tenant resolves from.
     667          438 :         if matches!(entry, CtxEntry::Cached(_) | CtxEntry::Core(_)) && tenant != TenantId::default()
     668              :         {
     669            8 :             return Err(ApiError::Denied(
     670            8 :                 "a Cached @context is shared by every Tenant: only the default Tenant deletes or reloads it"
     671            8 :                     .into(),
     672            8 :             ));
     673          430 :         }
     674          430 :         if reload {
     675              :             // reload is only meaningful for Cached @contexts (5.13.5.4);
     676              :             // other kinds are 400 (051_04_05)
     677           26 :             return match entry {
     678            4 :                 CtxEntry::Core(url) => {
     679            4 :                     st.loader.refetch(&url).await.map_err(ApiError::from)?;
     680            4 :                     Ok(no_content(&tenant))
     681              :                 }
     682           12 :                 CtxEntry::Cached(u) => {
     683           12 :                     st.loader.refetch(&u.url).await.map_err(ApiError::from)?;
     684            0 :                     Ok(no_content(&tenant))
     685              :                 }
     686           10 :                 CtxEntry::Stored(_) => Err(NgsiError::BadRequestData(
     687           10 :                     "reload is only valid for Cached @contexts (5.13.5)".into(),
     688           10 :                 )
     689           10 :                 .into()),
     690              :             };
     691          404 :         }
     692          404 :         match entry {
     693              :             CtxEntry::Core(_) => {
     694           40 :                 Err(NgsiError::BadRequestData("the core @context cannot be deleted".into()).into())
     695              :             }
     696          156 :             CtxEntry::Cached(u) => {
     697          156 :                 st.loader.usage_remove(&u.url).await;
     698              :                 // The write-through row shares the deterministic local id —
     699              :                 // deleting the API entry must delete the persisted copy too,
     700              :                 // or a restart resurrects a deleted @context (5.13.5).
     701          156 :                 st.store.context_delete(Some(&tenant), &u.local_id).await?;
     702          156 :                 Ok(no_content(&tenant))
     703              :             }
     704          208 :             CtxEntry::Stored(doc) => {
     705          208 :                 let lid = doc.get("localId").and_then(Value::as_str).unwrap_or(&id);
     706          208 :                 st.store.context_delete(Some(&tenant), lid).await?;
     707          208 :                 if let Some(url) = doc.get("url").and_then(Value::as_str) {
     708          208 :                     st.loader.usage_remove(url).await;
     709            0 :                 }
     710          208 :                 Ok::<_, ApiError>(no_content(&tenant))
     711              :             }
     712              :         }
     713          492 :     };
     714          492 :     go.await.unwrap_or_else(|e| e.into_response())
     715          492 : }
     716              : 
     717              : #[cfg(test)]
     718              : mod tests {
     719              :     use super::*;
     720              : 
     721           16 :     fn forged_host() -> HeaderMap {
     722           16 :         let mut h = HeaderMap::new();
     723           16 :         h.insert(header::HOST, "attacker.example".parse().expect("host"));
     724           16 :         h
     725           16 :     }
     726              : 
     727           24 :     fn hosted_row(url: &str, local_id: &str, owner: &TenantId) -> Value {
     728           24 :         json!({
     729           24 :             "url": url,
     730           24 :             "localId": local_id,
     731           24 :             "kind": "Hosted",
     732           24 :             "createdAt": now_iso(),
     733           24 :             "owner": owner.as_str(),
     734           24 :             "body": {"@context": {}},
     735              :         })
     736           24 :     }
     737              : 
     738              :     /// 5.13.1 + 5.5.10: purging a Tenant takes the @contexts authored through
     739              :     /// ITS requests and nothing else — one Tenant's deletion must not reach
     740              :     /// another Tenant's term mappings, and a Cached row is a copy of a public
     741              :     /// document belonging to no Tenant, so it survives every purge.
     742              :     #[tokio::test]
     743            4 :     async fn clause_5_13_1_a_tenant_purge_takes_only_its_own_contexts() {
     744            4 :         let st = AppState::new("antares-ctx-purge".into());
     745            4 :         let alpha = TenantId::new("alpha").expect("tenant");
     746            4 :         let beta = TenantId::new("beta").expect("tenant");
     747            4 :         let base = "http://broker.example/ngsi-ld/v1/jsonldContexts";
     748            8 :         for (lid, owner) in [("hosted-alpha", &alpha), ("hosted-beta", &beta)] {
     749            8 :             st.store
     750            8 :                 .context_put(
     751            8 :                     Some(owner),
     752            8 :                     lid,
     753            8 :                     hosted_row(&format!("{base}/{lid}"), lid, owner),
     754            8 :                 )
     755            8 :                 .await
     756            8 :                 .expect("store the Hosted @context");
     757              :         }
     758              :         // an ImplicitlyCreated wrapper is owned the same way a Hosted one is
     759            4 :         let mut implicit = hosted_row(&format!("{base}/implicit-alpha"), "implicit-alpha", &alpha);
     760            4 :         implicit["kind"] = json!("ImplicitlyCreated");
     761            4 :         st.store
     762            4 :             .context_put(Some(&alpha), "implicit-alpha", implicit)
     763            4 :             .await
     764            4 :             .expect("store the ImplicitlyCreated @context");
     765              :         // and a Cached copy, which carries no owner at all
     766            4 :         st.store
     767            4 :             .context_put(
     768            4 :                 None,
     769            4 :                 "cached-shared",
     770            4 :                 json!({"url": "https://example.org/ctx.jsonld", "localId": "cached-shared",
     771            4 :                        "kind": "Cached", "createdAt": now_iso()}),
     772            4 :             )
     773            4 :             .await
     774            4 :             .expect("store the Cached @context");
     775              :         // a legacy row with no owner member belongs to the default Tenant
     776            4 :         let mut legacy = hosted_row(&format!("{base}/legacy"), "legacy", &alpha);
     777            4 :         legacy.as_object_mut().expect("row object").remove("owner");
     778            4 :         st.store
     779            4 :             .context_put(Some(&TenantId::default()), "legacy", legacy)
     780            4 :             .await
     781            4 :             .expect("store legacy");
     782              : 
     783            4 :         purge_tenant(&st, &alpha).await.expect("purge alpha");
     784              : 
     785              :         // read as the row's own Tenant: a purge that took the row and a
     786              :         // store that hides it from its owner are different failures, and the
     787              :         // assertions below are about the first.
     788           28 :         async fn present(st: &AppState, owner: Option<&TenantId>, lid: &str) -> bool {
     789           28 :             st.store
     790           28 :                 .context_get(owner, lid)
     791           28 :                 .await
     792           28 :                 .expect("store read")
     793           28 :                 .is_some()
     794           28 :         }
     795            4 :         assert!(
     796            4 :             !present(&st, Some(&alpha), "hosted-alpha").await,
     797              :             "the purged Tenant's Hosted row goes"
     798              :         );
     799            4 :         assert!(
     800            4 :             !present(&st, Some(&alpha), "implicit-alpha").await,
     801              :             "and so does its ImplicitlyCreated wrapper"
     802              :         );
     803            4 :         assert!(
     804            4 :             present(&st, Some(&beta), "hosted-beta").await,
     805              :             "another Tenant's Hosted @context must survive a purge it has no part in"
     806              :         );
     807            4 :         assert!(
     808            4 :             present(&st, None, "cached-shared").await,
     809              :             "a Cached copy belongs to no Tenant and is not purged with one"
     810              :         );
     811            4 :         assert!(
     812            4 :             present(&st, Some(&TenantId::default()), "legacy").await,
     813              :             "an owner-less row belongs to the default Tenant, not to alpha"
     814              :         );
     815              :         // the same purge run against the default Tenant reaches the legacy row
     816            4 :         purge_tenant(&st, &TenantId::default())
     817            4 :             .await
     818            4 :             .expect("purge default");
     819            4 :         assert!(!present(&st, Some(&TenantId::default()), "legacy").await);
     820            4 :         assert!(
     821            4 :             present(&st, Some(&beta), "hosted-beta").await,
     822            4 :             "still beta's"
     823            4 :         );
     824            4 :     }
     825              : 
     826              :     /// 5.13.4.4: a stored @context resolves by its locally unique URI —
     827              :     /// the localId and the full published URL name the SAME entry (5.13.2.4),
     828              :     /// while a URL that only ends in a known localId names no entry at all,
     829              :     /// and another tenant's Hosted row stays invisible (5.13.1).
     830              :     #[tokio::test]
     831            4 :     async fn clause_5_13_4_entry_resolves_by_local_id_and_by_url() {
     832            4 :         let st = AppState::new("antares-ctx-find".into());
     833            4 :         let owner = TenantId::default();
     834            4 :         let other = TenantId::new("beta").expect("tenant");
     835            4 :         let local_id = "b2a1c0de-0000-4000-8000-000000000001";
     836            4 :         let url = format!("http://broker.example/ngsi-ld/v1/jsonldContexts/{local_id}");
     837            4 :         st.store
     838            4 :             .context_put(Some(&owner), local_id, hosted_row(&url, local_id, &owner))
     839            4 :             .await
     840            4 :             .expect("store the @context");
     841            4 :         assert!(
     842            4 :             find_entry(&st, &owner, local_id)
     843            4 :                 .await
     844            4 :                 .expect("store")
     845            4 :                 .is_some(),
     846              :             "the localId names the entry"
     847              :         );
     848            4 :         assert!(
     849            4 :             find_entry(&st, &owner, &url)
     850            4 :                 .await
     851            4 :                 .expect("store")
     852            4 :                 .is_some(),
     853              :             "the published URL names the same entry"
     854              :         );
     855            4 :         assert!(
     856            4 :             find_entry(&st, &other, &url)
     857            4 :                 .await
     858            4 :                 .expect("store")
     859            4 :                 .is_none(),
     860              :             "another tenant's Hosted @context is as absent as one that never existed"
     861              :         );
     862            4 :         let forged = format!("http://attacker.example/ngsi-ld/v1/jsonldContexts/{local_id}");
     863            4 :         assert!(
     864            4 :             find_entry(&st, &owner, &forged)
     865            4 :                 .await
     866            4 :                 .expect("store")
     867            4 :                 .is_none(),
     868              :             "a foreign URL ending in a known localId is not that entry"
     869              :         );
     870            4 :         assert!(find_entry(&st, &owner, "no-such-context")
     871            4 :             .await
     872            4 :             .expect("store")
     873            4 :             .is_none());
     874            4 :     }
     875              : 
     876              :     /// 5.13.2.4 stores the @context "supplied by the client" under a locally
     877              :     /// unique URI; 5.13.1 makes that Hosted entry the adding Tenant's own
     878              :     /// resource and 5.5.10 makes the Tenant the boundary an operation applies
     879              :     /// within. So the mappings expand the adding Tenant's payloads only —
     880              :     /// another Tenant handing the broker the same URL resolves nothing.
     881              :     #[tokio::test]
     882            4 :     async fn clause_5_13_1_added_context_expands_only_the_adding_tenant() {
     883            4 :         let st = AppState::new("antares-ctx-tenant".into());
     884            4 :         let alpha = TenantId::new("alpha").expect("tenant");
     885            4 :         let beta = TenantId::new("beta").expect("tenant");
     886            4 :         let mut headers = HeaderMap::new();
     887              :         // a dead address: the published URL cannot be fetched back over the
     888              :         // network, so a resolution that succeeds came from the stored entry
     889            4 :         headers.insert(header::HOST, "127.0.0.1:9".parse().expect("host"));
     890            4 :         headers.insert("NGSILD-Tenant", "alpha".parse().expect("tenant header"));
     891            4 :         let resp = add_context(
     892            4 :             State(st.clone()),
     893            4 :             CleanParams(HashMap::new()),
     894            4 :             headers.clone(),
     895            4 :             Bytes::from(r#"{"@context":{"secret":"https://alpha.example/secret"}}"#),
     896            4 :         )
     897            4 :         .await;
     898            4 :         assert_eq!(resp.status(), StatusCode::CREATED);
     899            4 :         let local_id = resp
     900            4 :             .headers()
     901            4 :             .get(header::LOCATION)
     902            4 :             .and_then(|v| v.to_str().ok())
     903            4 :             .and_then(|l| l.rsplit('/').next())
     904            4 :             .expect("Location names the new @context")
     905            4 :             .to_owned();
     906              :         // 5.13.2.4: "a locally unique identifier (URI) is generated"
     907            4 :         assert!(
     908            4 :             antares_model::EntityId::new(&local_id).is_ok(),
     909              :             "{local_id} is a URI"
     910              :         );
     911            4 :         let url = Value::String(format!("{}/{local_id}", base_url(&headers)));
     912              : 
     913            4 :         let ctx = st
     914            4 :             .loader
     915            4 :             .resolve_for(&alpha, &url)
     916            4 :             .await
     917            4 :             .expect("the adding Tenant resolves its own @context");
     918            4 :         assert_eq!(ctx.expand_key("secret"), "https://alpha.example/secret");
     919              : 
     920            4 :         let err = st
     921            4 :             .loader
     922            4 :             .resolve_for(&beta, &url)
     923            4 :             .await
     924            4 :             .expect_err("another Tenant must not resolve this @context");
     925            4 :         assert!(
     926            4 :             matches!(err, NgsiError::LdContextNotAvailable(_)),
     927              :             "a foreign Hosted @context is not available, got {err:?}"
     928              :         );
     929              :         // and the entry is invisible to the other Tenant through the API too
     930            4 :         assert!(
     931            4 :             find_entry(&st, &beta, &local_id)
     932            4 :                 .await
     933            4 :                 .expect("store")
     934            4 :                 .is_none(),
     935            4 :             "another Tenant's Hosted @context is as absent as one that never existed"
     936            4 :         );
     937            4 :     }
     938              : 
     939              :     /// 5.13.1 + 5.5.10 on the three handlers a client reaches, not only on the
     940              :     /// lookup they share: another Tenant's Hosted @context must not appear in
     941              :     /// the listing (with or without `details`, where the URL and the localId
     942              :     /// would both be readable), must not be served, and must not be deletable.
     943              :     /// A delete that answered 204 would be worse than a leak: the owning
     944              :     /// Tenant's term mappings would be gone with no error anywhere.
     945              :     #[tokio::test]
     946            4 :     async fn clause_5_13_1_the_handlers_hide_another_tenants_context() {
     947            4 :         let st = AppState::new("antares-ctx-crosstenant".into());
     948            4 :         let alpha = TenantId::new("alpha").expect("tenant");
     949            4 :         let lid = "b2a1c0de-0000-4000-8000-000000000002";
     950            4 :         let url = format!("http://broker.example/ngsi-ld/v1/jsonldContexts/{lid}");
     951            4 :         st.store
     952            4 :             .context_put(Some(&alpha), lid, hosted_row(&url, lid, &alpha))
     953            4 :             .await
     954            4 :             .expect("store the @context");
     955            4 :         let mut foreign = HeaderMap::new();
     956            4 :         foreign.insert("NGSILD-Tenant", "beta".parse().expect("tenant header"));
     957              : 
     958            8 :         for details in ["false", "true"] {
     959            8 :             let p: HashMap<String, String> = [("details".to_owned(), details.to_owned())]
     960            8 :                 .into_iter()
     961            8 :                 .collect();
     962            8 :             let resp = list_contexts(State(st.clone()), CleanParams(p), foreign.clone()).await;
     963            8 :             assert_eq!(resp.status(), StatusCode::OK);
     964            8 :             let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
     965            8 :                 .await
     966            8 :                 .expect("list body");
     967            8 :             let shown = String::from_utf8_lossy(&body);
     968            8 :             assert!(
     969            8 :                 !shown.contains(lid),
     970              :                 "details={details} listed another Tenant's @context: {shown}"
     971              :             );
     972              :         }
     973              : 
     974            8 :         for id in [lid.to_owned(), url.clone()] {
     975            8 :             let resp = serve_context(
     976            8 :                 State(st.clone()),
     977            8 :                 Path(id.clone()),
     978            8 :                 CleanParams(HashMap::new()),
     979            8 :                 foreign.clone(),
     980            8 :             )
     981            8 :             .await;
     982            8 :             assert_eq!(
     983            8 :                 resp.status(),
     984              :                 StatusCode::NOT_FOUND,
     985              :                 "serving {id} to another Tenant"
     986              :             );
     987            8 :             let resp = delete_context(
     988            8 :                 State(st.clone()),
     989            8 :                 Path(id.clone()),
     990            8 :                 CleanParams(HashMap::new()),
     991            8 :                 foreign.clone(),
     992            8 :             )
     993            8 :             .await;
     994            8 :             assert_eq!(
     995            8 :                 resp.status(),
     996              :                 StatusCode::NOT_FOUND,
     997              :                 "deleting {id} as another Tenant"
     998              :             );
     999              :         }
    1000            4 :         assert!(
    1001            4 :             st.store
    1002            4 :                 .context_get(Some(&alpha), lid)
    1003            4 :                 .await
    1004            4 :                 .expect("store")
    1005            4 :                 .is_some(),
    1006              :             "the owning Tenant's @context survived a foreign delete"
    1007              :         );
    1008              :         // and the owner still reaches it
    1009            4 :         let mut own = HeaderMap::new();
    1010            4 :         own.insert("NGSILD-Tenant", "alpha".parse().expect("tenant header"));
    1011            4 :         let resp = serve_context(
    1012            4 :             State(st.clone()),
    1013            4 :             Path(lid.to_owned()),
    1014            4 :             CleanParams(HashMap::new()),
    1015            4 :             own,
    1016            4 :         )
    1017            4 :         .await;
    1018            4 :         assert_eq!(resp.status(), StatusCode::OK, "the owner is served its own");
    1019            4 :     }
    1020              : 
    1021              :     /// Table 6.29.3.2-1: List @contexts defines `details` and `kind` only —
    1022              :     /// a pagination parameter this resource does not implement must be
    1023              :     /// refused (6.3.20 InvalidRequest), never accepted and ignored.
    1024              :     #[tokio::test]
    1025            4 :     async fn clause_6_29_3_2_list_takes_only_the_table_parameters() {
    1026            4 :         let st = AppState::new("antares-ctx-params".into());
    1027           16 :         for bad in ["limit", "offset", "count", "bogus"] {
    1028           16 :             let p: HashMap<String, String> =
    1029           16 :                 [(bad.to_owned(), "1".to_owned())].into_iter().collect();
    1030           16 :             let resp = list_contexts(State(st.clone()), CleanParams(p), HeaderMap::new()).await;
    1031           16 :             assert_eq!(
    1032           16 :                 resp.status(),
    1033            4 :                 StatusCode::BAD_REQUEST,
    1034            4 :                 "{bad:?} is not a List @contexts parameter"
    1035            4 :             );
    1036            4 :         }
    1037            8 :         for good in [("details", "true"), ("kind", "Hosted")] {
    1038            8 :             let p: HashMap<String, String> = [(good.0.to_owned(), good.1.to_owned())]
    1039            8 :                 .into_iter()
    1040            8 :                 .collect();
    1041            8 :             let resp = list_contexts(State(st.clone()), CleanParams(p), HeaderMap::new()).await;
    1042            8 :             assert_eq!(resp.status(), StatusCode::OK, "{good:?}");
    1043            4 :         }
    1044            4 :     }
    1045              : 
    1046              :     /// 5.13.2.4/5.13.3.5: the URI published for a broker-served @context
    1047              :     /// names the BROKER. It is taken from configuration, so a forged `Host`
    1048              :     /// header cannot make the broker advertise someone else's address, and a
    1049              :     /// TLS deployment is not stuck advertising `http`.
    1050              :     #[test]
    1051            4 :     fn published_context_url_comes_from_configuration_not_the_host_header() {
    1052            4 :         assert_eq!(
    1053            4 :             context_base(Some("https://broker.example"), &forged_host()),
    1054              :             "https://broker.example/ngsi-ld/v1/jsonldContexts"
    1055              :         );
    1056              :         // a configured value with a trailing slash must not double it
    1057            4 :         assert_eq!(
    1058            4 :             context_base(Some("https://broker.example/"), &forged_host()),
    1059              :             "https://broker.example/ngsi-ld/v1/jsonldContexts"
    1060              :         );
    1061              :         // nothing configured: today's Host-derived URL stays the fallback
    1062            4 :         assert_eq!(
    1063            4 :             context_base(None, &forged_host()),
    1064              :             "http://attacker.example/ngsi-ld/v1/jsonldContexts"
    1065              :         );
    1066            4 :         assert_eq!(
    1067            4 :             context_base(Some(""), &forged_host()),
    1068              :             "http://attacker.example/ngsi-ld/v1/jsonldContexts",
    1069              :             "an empty setting is no setting"
    1070              :         );
    1071            4 :     }
    1072              : }
        

Generated by: LCOV version 2.0-1