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