LCOV - code coverage report
Current view: top level - antares-api/src - contexts.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.3 % 586 576
Test Date: 2026-09-21 10:31:06 Functions: 68.1 % 185 126

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

Generated by: LCOV version 2.0-1