1/*
2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14use aide::{UseApi, transform::TransformOperation};
15use axum::{
16 Json,
17 extract::{Query, State},
18 http::StatusCode,
19 response::{IntoResponse as _, Response},
20};
21use axum_extra::extract::WithRejection;
22use cda_interfaces::{DynamicPlugin, SchemaProvider, UdsEcu, file_manager::FileManager};
23use cda_plugin_security::Secured;
24use sovd_interfaces::components::ecu::operations::OperationCollectionItem;
25
26use crate::sovd::{
27 WebserverEcuState, create_schema,
28 error::{ApiError, ErrorWrapper},
29 locks::require_ecu_access,
30};
31
32pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
33 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
34 WithRejection(Query(query), _): WithRejection<
35 Query<sovd_interfaces::IncludeSchemaQuery>,
36 ApiError,
37 >,
38 State(WebserverEcuState {
39 ecu_name,
40 uds,
41 locks,
42 ..
43 }): State<WebserverEcuState<T, U>>,
44) -> Response {
45 require_ecu_access!(
46 read,
47 security_plugin,
48 &ecu_name,
49 &locks,
50 query.include_schema
51 );
52 let security_plugin: DynamicPlugin = security_plugin;
53 match uds
54 .get_components_operations_info(&ecu_name, &security_plugin)
55 .await
56 {
57 Ok(items) => {
58 let schema = if query.include_schema {
59 Some(create_schema!(
60 sovd_interfaces::Items<OperationCollectionItem>
61 ))
62 } else {
63 None
64 };
65 (
66 StatusCode::OK,
67 Json(sovd_interfaces::Items {
68 items: items
69 .into_iter()
70 .map(|info| OperationCollectionItem {
71 id: info.id,
72 name: info.name,
73 proximity_proof_required: false,
74 asynchronous_execution: info.has_stop || info.has_request_results,
75 })
76 .collect(),
77 schema,
78 }),
79 )
80 .into_response()
81 }
82 Err(e) => ErrorWrapper {
83 error: e.into(),
84 include_schema: query.include_schema,
85 }
86 .into_response(),
87 }
88}
89
90pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
91 op.description("Get all available operations for this ECU component")
92 .response_with::<200, Json<sovd_interfaces::Items<OperationCollectionItem>>, _>(|res| {
93 res.description("List of operations available on this ECU.")
94 })
95}
96
97pub(crate) mod comparams {
98
99 pub(crate) mod executions {
100 use std::sync::Arc;
101
102 use aide::{UseApi, transform::TransformOperation};
103 use axum::{
104 Json,
105 extract::{OriginalUri, Path, Query, State},
106 http::{StatusCode, header},
107 response::{IntoResponse as _, Response},
108 };
109 use axum_extra::extract::WithRejection;
110 use cda_interfaces::{
111 HashMap, HashMapExtensions, UdsEcu, communication_control::CommunicationAccess,
112 file_manager::FileManager,
113 };
114 use cda_plugin_security::Secured;
115 use indexmap::IndexMap;
116 use opensovd_axum_extra::ExtractHost;
117 use sovd_interfaces::components::ecu::operations::comparams as sovd_comparams;
118 use tokio::sync::RwLock;
119 use uuid::Uuid;
120
121 use crate::sovd::{
122 ComparamExecution, IntoSovd, WebserverEcuState, acquire_communication_activity,
123 create_schema,
124 error::{ApiError, ErrorWrapper},
125 locks::require_ecu_access,
126 };
127
128 fn parse_exec_uuid(id: &str, include_schema: bool) -> Result<Uuid, ErrorWrapper> {
129 Uuid::parse_str(id).map_err(|e| ErrorWrapper {
130 error: ApiError::BadRequest(format!("{e:?}")),
131 include_schema,
132 })
133 }
134
135 pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
136 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
137 WithRejection(Query(query), _): WithRejection<
138 Query<sovd_comparams::executions::get::Query>,
139 ApiError,
140 >,
141 State(WebserverEcuState {
142 ecu_name,
143 locks,
144 comparam_executions,
145 ..
146 }): State<WebserverEcuState<T, U>>,
147 ) -> Response {
148 require_ecu_access!(
149 read,
150 security_plugin,
151 &ecu_name,
152 &locks,
153 query.include_schema
154 );
155 handler_read(comparam_executions, query.include_schema).await
156 }
157
158 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
159 op.description("Get all comparam executions")
160 .response_with::<200, Json<sovd_comparams::executions::get::Response>, _>(|res| {
161 res.description("Response with all comparam executions.")
162 .example(sovd_comparams::executions::get::Response {
163 items: vec![sovd_comparams::executions::Item {
164 id: "b7e2c1a2-3f4d-4e6a-9c8b-2a1d5e7f8c9b".to_string(),
165 }],
166 schema: None,
167 })
168 })
169 }
170
171 pub(crate) async fn post<T: UdsEcu + Clone, U: FileManager>(
172 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
173 WithRejection(Query(query), _): WithRejection<
174 Query<sovd_comparams::executions::get::Query>,
175 ApiError,
176 >,
177 State(WebserverEcuState {
178 ecu_name,
179 locks,
180 comparam_executions,
181 communication_access,
182 ..
183 }): State<WebserverEcuState<T, U>>,
184 UseApi(ExtractHost(host), _): UseApi<ExtractHost, String>,
185 OriginalUri(uri): OriginalUri,
186 request_body: Option<Json<sovd_comparams::executions::update::Request>>,
187 ) -> Response {
188 require_ecu_access!(
189 write,
190 security_plugin,
191 &ecu_name,
192 &locks,
193 query.include_schema
194 );
195 let path = format!("http://{host}{uri}");
196 let body = if let Some(Json(body)) = request_body {
197 Some(body)
198 } else {
199 None
200 };
201 handler_write(
202 comparam_executions,
203 communication_access,
204 path,
205 body,
206 query.include_schema,
207 )
208 .await
209 }
210
211 pub(crate) fn docs_post(op: TransformOperation) -> TransformOperation {
212 op.description("Create a new comparam execution")
213 .response_with::<202, Json<sovd_comparams::executions::update::Response>, _>(
214 |res| {
215 res.description("Comparam execution created successfully.")
216 .example(sovd_comparams::executions::update::Response {
217 id: "b7e2c1a2-3f4d-4e6a-9c8b-2a1d5e7f8c9b".to_string(),
218 status: sovd_comparams::executions::Status::Running,
219 schema: None,
220 })
221 },
222 )
223 }
224
225 pub(crate) async fn handler_read(
226 executions: Arc<RwLock<IndexMap<Uuid, ComparamExecution>>>,
227 include_schema: bool,
228 ) -> Response {
229 let schema = if include_schema {
230 Some(create_schema!(sovd_comparams::executions::get::Response))
231 } else {
232 None
233 };
234 (
235 StatusCode::OK,
236 Json(sovd_comparams::executions::get::Response {
237 items: executions
238 .read()
239 .await
240 .keys()
241 .map(|id| sovd_comparams::executions::Item { id: id.to_string() })
242 .collect::<Vec<_>>(),
243 schema,
244 }),
245 )
246 .into_response()
247 }
248 async fn handler_write(
249 executions: Arc<RwLock<IndexMap<Uuid, ComparamExecution>>>,
250 communication_access: Arc<dyn CommunicationAccess>,
251 base_path: String,
252 request: Option<sovd_comparams::executions::update::Request>,
253 include_schema: bool,
254 ) -> Response {
255 // todo: not in scope for now: request can take body with
256 // { timeout: INT, parameters: { ... }, proximity_response: STRING }
257 let communication_activity =
258 match acquire_communication_activity(&*communication_access) {
259 Ok(activity) => activity,
260 Err(error) => {
261 return ErrorWrapper {
262 error,
263 include_schema,
264 }
265 .into_response();
266 }
267 };
268 let id = Uuid::new_v4();
269 let mut comparam_override: HashMap<String, sovd_comparams::ComParamValue> =
270 HashMap::new();
271
272 if let Some(sovd_comparams::executions::update::Request {
273 parameters: Some(parameters),
274 ..
275 }) = request
276 {
277 for (k, v) in parameters {
278 comparam_override.insert(k, v);
279 }
280 }
281
282 let schema = if include_schema {
283 Some(create_schema!(sovd_comparams::executions::update::Response))
284 } else {
285 None
286 };
287
288 let create_execution_response = sovd_comparams::executions::update::Response {
289 id: id.to_string(),
290 status: sovd_comparams::executions::Status::Running,
291 schema,
292 };
293 // Publish the lease before the execution becomes observable to DELETE.
294 let mut executions = executions.write().await;
295 executions.insert(
296 id,
297 ComparamExecution::new(
298 sovd_comparams::Execution {
299 capability: sovd_comparams::executions::Capability::Execute,
300 status: create_execution_response.status.clone(),
301 comparam_override,
302 },
303 communication_activity,
304 ),
305 );
306 (
307 StatusCode::ACCEPTED,
308 [(header::LOCATION, format!("{base_path}/{id}"))],
309 Json(create_execution_response),
310 )
311 .into_response()
312 }
313
314 pub(crate) mod id {
315 use super::*;
316 use crate::{openapi, sovd::components::IdPathParam};
317 pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
318 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
319 Path(id): Path<IdPathParam>,
320 WithRejection(Query(query), _): WithRejection<
321 Query<sovd_comparams::executions::get::Query>,
322 ApiError,
323 >,
324 State(WebserverEcuState {
325 ecu_name,
326 uds,
327 locks,
328 comparam_executions,
329 ..
330 }): State<WebserverEcuState<T, U>>,
331 ) -> Response {
332 let include_schema = query.include_schema;
333 require_ecu_access!(read, security_plugin, &ecu_name, &locks, include_schema);
334 let id = match parse_exec_uuid(&id, include_schema) {
335 Ok(v) => v,
336 Err(e) => return e.into_response(),
337 };
338 let mut executions: Vec<sovd_comparams::Execution> = Vec::new();
339
340 let (idx, execution) = match comparam_executions
341 .read()
342 .await
343 .get_full(&id)
344 .ok_or_else(|| {
345 ApiError::NotFound(Some(format!("Execution with id {id} not found")))
346 }) {
347 Ok((idx, _, exec)) => (idx, exec.snapshot()),
348 Err(e) => {
349 return ErrorWrapper {
350 error: e,
351 include_schema,
352 }
353 .into_response();
354 }
355 };
356 let capability = execution.capability.clone();
357 let status = execution.status.clone();
358
359 // put in all executions with lower index than this one
360 for (_, v) in &comparam_executions.read().await.as_slice()[..idx] {
361 executions.push(v.snapshot());
362 }
363 executions.push(execution);
364
365 let mut parameters = match uds.get_comparams(&ecu_name).await {
366 Ok(v) => v.into_sovd(),
367 Err(e) => {
368 return ErrorWrapper {
369 error: e.into(),
370 include_schema,
371 }
372 .into_response();
373 }
374 };
375
376 for (k, v) in executions.into_iter().flat_map(|e| e.comparam_override) {
377 parameters.insert(k, v);
378 }
379
380 let schema = if include_schema {
381 Some(create_schema!(
382 sovd_comparams::executions::id::get::Response
383 ))
384 } else {
385 None
386 };
387
388 (
389 StatusCode::OK,
390 Json(sovd_comparams::executions::id::get::Response {
391 capability,
392 parameters,
393 status,
394 schema,
395 }),
396 )
397 .into_response()
398 }
399
400 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
401 op.description("Get a specific comparam execution")
402 .response_with::<200, Json<sovd_comparams::executions::id::get::Response>, _>(
403 |res| {
404 res.description("Response with comparam execution details.")
405 .example(sovd_comparams::executions::id::get::Response {
406 capability: sovd_comparams::executions::Capability::Execute,
407 parameters: HashMap::new(),
408 status: sovd_comparams::executions::Status::Running,
409 schema: None,
410 })
411 },
412 )
413 .with(openapi::comparam_execution_errors)
414 }
415
416 pub(crate) async fn delete<T: UdsEcu + Clone, U: FileManager>(
417 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
418 Path(id): Path<IdPathParam>,
419 State(WebserverEcuState {
420 ecu_name,
421 locks,
422 comparam_executions,
423 ..
424 }): State<WebserverEcuState<T, U>>,
425 ) -> Response {
426 require_ecu_access!(write, security_plugin, &ecu_name, &locks, false);
427 let id = match parse_exec_uuid(&id, false) {
428 Ok(v) => v,
429 Err(e) => return e.into_response(),
430 };
431 let removed = {
432 let mut executions = comparam_executions.write().await;
433 executions.shift_remove(&id).is_some()
434 };
435 if !removed {
436 return ErrorWrapper {
437 error: ApiError::NotFound(Some(format!(
438 "Execution with id {id} not found"
439 ))),
440 include_schema: false,
441 }
442 .into_response();
443 }
444 StatusCode::NO_CONTENT.into_response()
445 }
446
447 pub(crate) fn docs_delete(op: TransformOperation) -> TransformOperation {
448 op.description("Delete a specific comparam execution")
449 .response_with::<204, (), _>(|res| {
450 res.description("Comparam execution deleted successfully.")
451 })
452 .with(openapi::comparam_execution_errors)
453 }
454
455 pub(crate) async fn put<T: UdsEcu + Clone, U: FileManager>(
456 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
457 Path(id): Path<IdPathParam>,
458 WithRejection(Query(query), _): WithRejection<
459 Query<sovd_comparams::executions::update::Query>,
460 ApiError,
461 >,
462 State(WebserverEcuState {
463 ecu_name,
464 locks,
465 comparam_executions,
466 ..
467 }): State<WebserverEcuState<T, U>>,
468 UseApi(ExtractHost(host), _): UseApi<ExtractHost, String>,
469 OriginalUri(uri): OriginalUri,
470 WithRejection(Json(request), _): WithRejection<
471 Json<sovd_comparams::executions::update::Request>,
472 ApiError,
473 >,
474 ) -> Response {
475 let include_schema = query.include_schema;
476 require_ecu_access!(write, security_plugin, &ecu_name, &locks, include_schema);
477 let id = match parse_exec_uuid(&id, include_schema) {
478 Ok(v) => v,
479 Err(e) => return e.into_response(),
480 };
481 let path = format!("http://{host}{uri}");
482 // todo: (out of scope for now) handle timout and capability
483
484 // todo: validate that the passed in CP is actually a valid CP for the ECU
485 // let mut comparams = match uds.get_comparams(&ecu_name).await {
486 // Ok(v) => v,
487 // Err(e) => return ErrorWrapper(ApiError::BadRequest(e)).into_response(),
488 // };
489
490 let mut executions_lock = comparam_executions.write().await;
491 let execution: &mut ComparamExecution =
492 match executions_lock.get_mut(&id).ok_or_else(|| {
493 ApiError::NotFound(Some(format!("Execution with id {id} not found")))
494 }) {
495 Ok(v) => v,
496 Err(e) => {
497 return ErrorWrapper {
498 error: e,
499 include_schema,
500 }
501 .into_response();
502 }
503 };
504
505 if let Some(comparam_values) = request.parameters {
506 for (k, v) in comparam_values {
507 execution.comparam_override.insert(k, v);
508 }
509 }
510
511 let schema = if include_schema {
512 Some(create_schema!(sovd_comparams::executions::update::Response))
513 } else {
514 None
515 };
516
517 (
518 StatusCode::ACCEPTED,
519 [(header::LOCATION, path)],
520 Json(sovd_comparams::executions::update::Response {
521 id: id.to_string(),
522 status: execution.status.clone(),
523 schema,
524 }),
525 )
526 .into_response()
527 }
528
529 pub(crate) fn docs_put(op: TransformOperation) -> TransformOperation {
530 op.description("Update a specific comparam execution")
531 .response_with::<202, Json<sovd_comparams::executions::update::Response>, _>(
532 |res| {
533 res.description("Comparam execution updated successfully.")
534 .example(sovd_comparams::executions::update::Response {
535 id: "example_id".to_string(),
536 status: sovd_comparams::executions::Status::Running,
537 schema: None,
538 })
539 },
540 )
541 .with(openapi::comparam_execution_errors)
542 }
543 }
544 }
545}
546
547pub(crate) mod service {
548 use aide::UseApi;
549 use axum::extract::{Path, Query, State};
550 use axum_extra::extract::WithRejection;
551
552 use crate::sovd::locks::require_ecu_access;
553
554 /// `GET /operations/{service}` - get operation details or SDGs
[docs] 555 // [[ dimpl~sovd-api-component-operations-sdgsd, GET /operations/{service} SDG handler ]]
556 pub(crate) async fn get<
557 T: cda_interfaces::UdsEcu + cda_interfaces::SchemaProvider + Clone,
558 U: cda_interfaces::file_manager::FileManager,
559 >(
560 UseApi(cda_plugin_security::Secured(security_plugin), _): UseApi<
561 cda_plugin_security::Secured,
562 (),
563 >,
564 Path(docs_endpoint::OperationNamePathParam { service }): Path<
565 docs_endpoint::OperationNamePathParam,
566 >,
567 WithRejection(Query(query), _): WithRejection<
568 Query<sovd_interfaces::components::ComponentQuery>,
569 crate::sovd::error::ApiError,
570 >,
571 State(crate::sovd::WebserverEcuState {
572 ecu_name,
573 uds,
574 locks,
575 ..
576 }): State<crate::sovd::WebserverEcuState<T, U>>,
577 ) -> axum::response::Response {
578 use axum::response::IntoResponse as _;
579
580 let include_schema = query.include_schema;
581 require_ecu_access!(read, security_plugin, &ecu_name, &locks, include_schema);
582 if query.include_sdgs {
583 return get_sdgs_handler::<T>(service, &ecu_name, &uds, include_schema).await;
584 }
585
586 // Normal response: return operation info for the named service
587 let security_plugin: cda_interfaces::DynamicPlugin = security_plugin;
588 let ops_info = match uds
589 .get_components_operations_info(&ecu_name, &security_plugin)
590 .await
591 {
592 Ok(info) => info,
593 Err(e) => {
594 return crate::sovd::error::ErrorWrapper {
595 error: e.into(),
596 include_schema,
597 }
598 .into_response();
599 }
600 };
601
602 let Some(op_info) = ops_info
603 .iter()
604 .find(|o| o.id.eq_ignore_ascii_case(&service))
605 else {
606 return crate::sovd::error::ApiError::NotFound(Some(format!(
607 "Operation '{service}' not found"
608 )))
609 .into_response();
610 };
611
612 let schema = if include_schema {
613 Some(crate::sovd::create_schema!(
614 sovd_interfaces::components::ecu::operations::OperationCollectionItem
615 ))
616 } else {
617 None
618 };
619
620 (
621 http::StatusCode::OK,
622 axum::Json(sovd_interfaces::Items {
623 items: vec![
624 sovd_interfaces::components::ecu::operations::OperationCollectionItem {
625 id: op_info.id.clone(),
626 name: op_info.name.clone(),
627 proximity_proof_required: false,
628 asynchronous_execution: op_info.has_stop || op_info.has_request_results,
629 },
630 ],
631 schema,
632 }),
633 )
634 .into_response()
635 }
636
637 async fn get_sdgs_handler<T: cda_interfaces::UdsEcu + Clone>(
638 service: String,
639 ecu_name: &str,
640 gateway: &T,
641 include_schema: bool,
642 ) -> axum::response::Response {
643 use axum::response::IntoResponse as _;
644 use cda_interfaces::{DiagComm, DiagCommType, HashMap, HashMapExtensions};
645
646 use crate::sovd::IntoSovd;
647
648 let service_ops = vec![
649 DiagComm {
650 name: service.clone(),
651 type_: DiagCommType::Operations,
652 lookup_name: None,
653 subfunction_id: None,
654 },
655 DiagComm {
656 name: service.clone(),
657 type_: DiagCommType::Operations,
658 lookup_name: None,
659 subfunction_id: None,
660 },
661 DiagComm {
662 name: service,
663 type_: DiagCommType::Operations,
664 lookup_name: None,
665 subfunction_id: None,
666 },
667 ];
668 let schema = if include_schema {
669 Some(crate::sovd::create_schema!(
670 sovd_interfaces::components::ecu::ServicesSdgs
671 ))
672 } else {
673 None
674 };
675 let mut resp = sovd_interfaces::components::ecu::ServicesSdgs {
676 items: HashMap::new(),
677 schema,
678 };
679 for service in service_ops {
680 match gateway.get_sdgs(ecu_name, Some(&service)).await {
681 Ok(sdgs) => {
682 if sdgs.is_empty() {
683 continue;
684 }
685 resp.items.insert(
686 format!("{}_{:?}", service.name, service.action()).to_lowercase(),
687 sovd_interfaces::components::ecu::ServiceSdgs {
688 sdgs: sdgs.into_sovd(),
689 },
690 );
691 }
692 Err(e) => {
693 return crate::sovd::error::ErrorWrapper {
694 error: e.into(),
695 include_schema,
696 }
697 .into_response();
698 }
699 }
700 }
701 (http::StatusCode::OK, axum::Json(resp)).into_response()
702 }
703
704 pub(crate) fn docs_get(
705 op: aide::transform::TransformOperation,
706 ) -> aide::transform::TransformOperation {
707 use aide::transform::TransformParameter;
708 op.description("Get a specific operation or its SDG metadata.")
709 .parameter("x-sovd2uds-includesdgs", |op: TransformParameter<bool>| {
710 op.description("Set to true to include sdgs.")
711 })
712 .response_with::<200, axum::Json<
713 sovd_interfaces::Items<
714 sovd_interfaces::components::ecu::operations::OperationCollectionItem,
715 >,
716 >, _>(|res| res.description("Operation details or SDG metadata."))
717 .with(crate::openapi::error_not_found)
718 .with(crate::openapi::error_internal_server)
719 }
720
721 /// `GET /operations/{service}/docs` - online capability description
722 pub(crate) mod docs_endpoint {
723 use aide::{UseApi, openapi::OpenApi, transform::TransformOperation};
724 use axum::{
725 Json,
726 extract::{Path, State},
727 http::StatusCode,
728 response::{IntoResponse as _, Response},
729 };
730 use cda_interfaces::{
731 DiagComm, DiagCommType, DynamicPlugin, SchemaProvider, UdsEcu,
732 file_manager::FileManager, subfunction_ids,
733 };
734 use cda_plugin_security::Secured;
735
736 use crate::{
737 openapi,
738 sovd::{
739 WebserverEcuState,
740 docs::{self, operations::OperationDocsMeta},
741 error::ApiError,
742 locks::require_ecu_access,
743 },
744 };
745
746 openapi::aide_helper::gen_path_param!(OperationNamePathParam service String);
747
748 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
749 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
750 Path(OperationNamePathParam { service }): Path<OperationNamePathParam>,
751 State(WebserverEcuState {
752 ecu_name,
753 uds,
754 locks,
755 ..
756 }): State<WebserverEcuState<T, U>>,
757 ) -> Response {
758 require_ecu_access!(read, security_plugin, &ecu_name, &locks, false);
759 let security_plugin: DynamicPlugin = security_plugin;
760
761 let ops_info = match uds
762 .get_components_operations_info(&ecu_name, &security_plugin)
763 .await
764 {
765 Ok(info) => info,
766 Err(e) => return ApiError::from(e).into_response(),
767 };
768
769 let Some(op_info) = ops_info
770 .iter()
771 .find(|o| o.id.eq_ignore_ascii_case(&service))
772 else {
773 return ApiError::NotFound(Some(format!("Operation '{service}' not found")))
774 .into_response();
775 };
776
777 let is_async = op_info.has_stop || op_info.has_request_results;
778
779 let diag_service = DiagComm {
780 name: service.clone(),
781 type_: DiagCommType::Operations,
782 lookup_name: None,
783 subfunction_id: Some(subfunction_ids::routine::START),
784 };
785
786 let request_params_schema = uds
787 .schema_for_request(&ecu_name, &diag_service)
788 .await
789 .ok()
790 .and_then(cda_interfaces::SchemaDescription::into_schema);
791
792 let response_params_schema = uds
793 .schema_for_responses(&ecu_name, &diag_service)
794 .await
795 .ok()
796 .and_then(cda_interfaces::SchemaDescription::into_schema);
797
798 let meta = OperationDocsMeta {
799 name: service.clone(),
800 is_async,
801 request_params_schema,
802 response_params_schema,
803 };
804
805 let base_path = format!("/components/{ecu_name}/operations/{service}");
806 let path_items = docs::operations::build_path_items(&base_path, &meta);
807 let doc = docs::build_openapi_doc(&format!("Operation: {service}"), path_items);
808
809 (StatusCode::OK, Json(doc)).into_response()
810 }
811
812 pub(crate) fn docs_transform(op: TransformOperation) -> TransformOperation {
813 op.description(
814 "Online capability description for a specific operation on this ECU component \
815 (ISO 17978-3 Section 7.5). Returns a self-contained OpenAPI specification.",
816 )
817 .response_with::<200, Json<OpenApi>, _>(|res| {
818 res.description("Self-contained OpenAPI 3.1 specification for this operation.")
819 })
820 .with(openapi::error_not_found)
821 }
822 }
823
824 pub(crate) mod executions {
825 use std::sync::{Arc, RwLock};
826
827 use aide::{UseApi, transform::TransformOperation};
828 use axum::{
829 Json,
830 body::Bytes,
831 extract::{OriginalUri, Path, Query, State},
832 http::{HeaderMap, StatusCode, header},
833 response::{IntoResponse as _, Response},
834 };
835 use axum_extra::extract::WithRejection;
836 use cda_interfaces::{
837 DiagComm, DiagCommType, DiagServiceError, DynamicPlugin, SchemaProvider, UdsEcu,
838 communication_control::CommunicationAccess,
839 diagservices::{DiagServiceJsonResponse, DiagServiceResponse, DiagServiceResponseType},
840 file_manager::FileManager,
841 subfunction_ids,
842 util::std_ext::{lock_read, lock_write},
843 };
844 use cda_plugin_security::{Secured, SecurityPlugin};
845 use opensovd_axum_extra::ExtractHost;
846 use sovd_interfaces::{
847 common::operations::OperationIdItem,
848 components::ecu::operations::{
849 AsyncGetByIdResponse, AsyncPostResponse, ExecutionStatus, OperationDeleteQuery,
850 OperationQuery, service::executions as sovd_executions,
851 },
852 };
853 use uuid::Uuid;
854
855 use crate::{
856 openapi,
857 sovd::{
858 self, ExecutionReservation, ServiceExecution, WebserverEcuState,
859 acquire_and_reserve_execution, api_error_from_diag_response,
860 components::get_content_type_and_accept,
861 create_response_schema, create_schema,
862 error::{ApiError, ErrorWrapper, VendorErrorCode},
863 field_parse_errors_to_json, guard_execution,
864 locks::require_ecu_access,
865 },
866 };
867
868 openapi::aide_helper::gen_path_param!(OperationServicePathParam service String);
869
870 /// Options forwarded from the HTTP layer into `ecu_operation_write_handler`.
871 pub(crate) struct WriteHandlerOptions {
872 pub include_schema: bool,
873 pub suppress_service: bool,
874 pub base_path: String,
875 }
876
877 /// Request data forwarded from the HTTP layer into `ecu_operation_write_handler`.
878 pub(crate) struct WriteHandlerRequest {
879 pub service: String,
880 pub headers: HeaderMap,
881 pub body: Bytes,
882 }
883
884 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
885 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
886 Path(OperationServicePathParam { service }): Path<OperationServicePathParam>,
887 WithRejection(Query(query), _): WithRejection<Query<sovd_executions::Query>, ApiError>,
888 State(WebserverEcuState {
889 ecu_name,
890 locks,
891 service_executions,
892 ..
893 }): State<WebserverEcuState<T, U>>,
894 ) -> Response {
895 require_ecu_access!(
896 read,
897 security_plugin,
898 &ecu_name,
899 &locks,
900 query.include_schema
901 );
902 let schema = if query.include_schema {
903 Some(create_schema!(sovd_interfaces::Items<OperationIdItem>))
904 } else {
905 None
906 };
907 let ids: Vec<OperationIdItem> = lock_read(&service_executions)
908 .get(&service)
909 .map(|op_map| {
910 op_map
911 .iter()
912 .filter(|(_, v)| v.is_created)
913 .map(|(k, _)| OperationIdItem { id: k.to_string() })
914 .collect()
915 })
916 .unwrap_or_default();
917 (
918 StatusCode::OK,
919 Json(sovd_interfaces::Items { items: ids, schema }),
920 )
921 .into_response()
922 }
923
924 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
925 op.description("List all active service operation executions")
926 .response_with::<200, Json<sovd_interfaces::Items<String>>, _>(|res| {
927 res.description("List of active execution ids.")
928 })
929 }
930
931 #[allow(
932 clippy::too_many_arguments,
933 reason = "Axum extractors cannot be combined without a new custom extractor"
934 )]
935 pub(crate) async fn post<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
936 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
937 Path(OperationServicePathParam { service }): Path<OperationServicePathParam>,
938 WithRejection(Query(query), _): WithRejection<Query<OperationQuery>, ApiError>,
939 State(WebserverEcuState {
940 ecu_name,
941 uds,
942 locks,
943 service_executions,
944 communication_access,
945 ..
946 }): State<WebserverEcuState<T, U>>,
947 UseApi(ExtractHost(host), _): UseApi<ExtractHost, String>,
948 OriginalUri(uri): OriginalUri,
949 headers: HeaderMap,
950 body: Bytes,
951 ) -> Response {
952 require_ecu_access!(
953 write,
954 security_plugin,
955 &ecu_name,
956 &locks,
957 query.include_schema
958 );
959 let ctx = OperationWriteContext::new(service_executions, communication_access);
960 ecu_operation_write_handler_with_activity::<T>(
961 WriteHandlerRequest {
962 service,
963 headers,
964 body,
965 },
966 &ecu_name,
967 &uds,
968 ctx,
969 security_plugin,
970 WriteHandlerOptions {
971 include_schema: query.include_schema,
972 suppress_service: query.suppress_service,
973 base_path: format!("http://{host}{uri}"),
974 },
975 )
976 .await
977 }
978
979 pub(crate) fn docs_post(op: TransformOperation) -> TransformOperation {
980 openapi::request_json_and_octet::<sovd_executions::Request>(op)
981 .description("Start a new operation execution (Start subfunction)")
982 .response_with::<200, Json<sovd_executions::Response<VendorErrorCode>>, _>(|res| {
983 let mut res = res
984 .description("Execution started, synchronous result.")
985 .example(sovd_executions::Response {
986 parameters: Some(serde_json::Map::from_iter([(
987 "example_param".to_string(),
988 serde_json::Value::String("example_value".to_string()),
989 )])),
990 error: None,
991 schema: None,
992 });
993 res.inner().content.insert(
994 "application/octet-stream".to_owned(),
995 aide::openapi::MediaType {
996 example: Some(serde_json::json!([0xABu8, 0xCD, 0xEF, 0x00])),
997 ..Default::default()
998 },
999 );
1000 res
1001 })
1002 .response_with::<
1003 202,
1004 Json<
1005 AsyncPostResponse<
1006 serde_json::Map<String, serde_json::Value>,
1007 VendorErrorCode,
1008 >,
1009 >,
1010 _,
1011 >(|res| {
1012 res.description(
1013 "Execution started asynchronously. Use the returned id for GET/DELETE on \
1014 /executions/{id}.",
1015 )
1016 })
1017 .with(openapi::error_bad_request)
1018 .with(openapi::error_not_found)
1019 .with(openapi::error_forbidden)
1020 .with(openapi::error_conflict)
1021 .with(openapi::error_internal_server)
1022 .with(openapi::error_bad_gateway)
1023 }
1024
1025 fn validate_and_parse_write_request(
1026 headers: &HeaderMap,
1027 body: &Bytes,
1028 ) -> Result<(Option<cda_interfaces::diagservices::UdsPayloadData>, bool), ApiError>
1029 {
1030 let (Some(content_type), accept) = get_content_type_and_accept(headers)? else {
1031 return Err(ApiError::BadRequest("Missing Content-Type".to_owned()));
1032 };
1033 let data = sovd::get_payload_data::<sovd_executions::Request>(
1034 Some(&content_type),
1035 headers,
1036 body,
1037 )?;
1038 if accept != mime::APPLICATION_OCTET_STREAM && accept != mime::APPLICATION_JSON {
1039 return Err(ApiError::BadRequest(format!(
1040 "Unsupported Accept header: {accept:?}"
1041 )));
1042 }
1043 Ok((data, accept == mime::APPLICATION_JSON))
1044 }
1045
1046 /// Context for ECU operation write handlers, grouping execution-related state.
1047 pub(crate) struct OperationWriteContext {
1048 service_executions: Arc<
1049 RwLock<cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>>,
1050 >,
1051 communication_access: Arc<dyn CommunicationAccess>,
1052 }
1053
1054 impl OperationWriteContext {
1055 pub(crate) fn new(
1056 service_executions: Arc<
1057 RwLock<
1058 cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
1059 >,
1060 >,
1061 communication_access: Arc<dyn CommunicationAccess>,
1062 ) -> Self {
1063 Self {
1064 service_executions,
1065 communication_access,
1066 }
1067 }
1068 }
1069
1070 pub(crate) async fn ecu_operation_write_handler_with_activity<
1071 T: UdsEcu + SchemaProvider + Clone,
1072 >(
1073 req: WriteHandlerRequest,
1074 ecu_name: &str,
1075 uds: &T,
1076 ctx: OperationWriteContext,
1077 security_plugin: Box<dyn SecurityPlugin>,
1078 opts: WriteHandlerOptions,
1079 ) -> Response {
1080 let OperationWriteContext {
1081 service_executions,
1082 communication_access,
1083 } = ctx;
1084 let WriteHandlerRequest {
1085 service,
1086 headers,
1087 body,
1088 } = req;
1089 let WriteHandlerOptions {
1090 include_schema,
1091 suppress_service,
1092 base_path,
1093 } = opts;
1094 let err_response = |error: ApiError| -> Response {
1095 ErrorWrapper {
1096 error,
1097 include_schema,
1098 }
1099 .into_response()
1100 };
1101 if service == "reset" {
1102 return ecu_reset_handler::<T>(
1103 service,
1104 ecu_name,
1105 uds,
1106 body,
1107 security_plugin,
1108 include_schema,
1109 )
1110 .await;
1111 }
1112
1113 // Reserve an execution slot atomically: checks for a running
1114 // conflict and, if none, inserts a placeholder so that a second
1115 // concurrent POST for the same operation sees 409 Conflict.
1116 // The lease is the active-execution marker for update arbitration. It is
1117 // published with the reservation and retained until the entry is removed.
1118 let reservation = match acquire_and_reserve_execution(
1119 &*communication_access,
1120 Arc::clone(&service_executions),
1121 &service,
1122 &service,
1123 include_schema,
1124 ) {
1125 Ok(v) => v,
1126 Err(e) => return e.into_response(),
1127 };
1128
1129 let security_plugin: DynamicPlugin = security_plugin;
1130 let ResolvedOperation {
1131 is_async,
1132 operation: diag_service,
1133 } = if suppress_service {
1134 ResolvedOperation {
1135 is_async: true,
1136 operation: DiagComm {
1137 name: service.clone(),
1138 type_: DiagCommType::Operations,
1139 lookup_name: None,
1140 subfunction_id: Some(subfunction_ids::routine::START),
1141 },
1142 }
1143 } else {
1144 match resolve_operation(uds, ecu_name, &service, &security_plugin).await {
1145 Ok(v) => v,
1146 Err(e) => {
1147 return err_response(e);
1148 }
1149 }
1150 };
1151
1152 let (data, map_to_json) = match validate_and_parse_write_request(&headers, &body) {
1153 Ok(v) => v,
1154 Err(e) => {
1155 return err_response(e);
1156 }
1157 };
1158
1159 let response = if suppress_service {
1160 None
1161 } else {
1162 match send_start_request(
1163 uds,
1164 ecu_name,
1165 diag_service.clone(),
1166 &security_plugin,
1167 data,
1168 map_to_json,
1169 include_schema,
1170 )
1171 .await
1172 {
1173 Ok(r) => r,
1174 Err(e) => {
1175 return *e;
1176 }
1177 }
1178 };
1179 finish_write_request(FinishWriteRequestArgs {
1180 is_async,
1181 response,
1182 map_to_json,
1183 include_schema,
1184 base_path,
1185 reservation,
1186 ecu_name,
1187 uds,
1188 diag_service,
1189 })
1190 .await
1191 }
1192
1193 /// Arguments for [`finish_write_request`].
1194 struct FinishWriteRequestArgs<'a, T: UdsEcu + SchemaProvider + Clone> {
1195 is_async: bool,
1196 response: Option<T::Response>,
1197 map_to_json: bool,
1198 include_schema: bool,
1199 base_path: String,
1200 reservation: ExecutionReservation<ServiceExecution>,
1201 ecu_name: &'a str,
1202 uds: &'a T,
1203 diag_service: DiagComm,
1204 }
1205
1206 /// Dispatch the tail of a write request to the async or sync
1207 /// post-processing path.
1208 async fn finish_write_request<T: UdsEcu + SchemaProvider + Clone>(
1209 args: FinishWriteRequestArgs<'_, T>,
1210 ) -> Response {
1211 let FinishWriteRequestArgs {
1212 is_async,
1213 response,
1214 map_to_json,
1215 include_schema,
1216 base_path,
1217 reservation,
1218 ecu_name,
1219 uds,
1220 diag_service,
1221 } = args;
1222 if is_async {
1223 handle_async_post::<T>(response, include_schema, &base_path, reservation)
1224 } else {
1225 drop(reservation);
1226 handle_sync_post::<T>(
1227 response,
1228 map_to_json,
1229 include_schema,
1230 ecu_name,
1231 uds,
1232 &diag_service,
1233 )
1234 .await
1235 }
1236 }
1237
1238 struct ResolvedOperation {
1239 is_async: bool,
1240 operation: DiagComm,
1241 }
1242
1243 /// Resolves an operation-id to its underlying UDS service type and addressing scheme:
1244 /// a `RoutineControl` group addressed through its Start subfunction, or an IO Control
1245 /// (and any other non-routine operation) addressed by its exact diagnostic service name.
1246 ///
1247 /// Each type is checked directly rather than inferring IO Control from the absence of a
1248 /// `RoutineControl` service, so an operation-id that matches neither fails immediately
1249 /// with `NotFound` instead of falling through into a doomed `send()` attempt.
1250 async fn resolve_operation<T: UdsEcu>(
1251 uds: &T,
1252 ecu_name: &str,
1253 service: &str,
1254 security_plugin: &DynamicPlugin,
1255 ) -> Result<ResolvedOperation, ApiError> {
1256 match uds
1257 .get_routine_subfunctions(ecu_name, service, security_plugin)
1258 .await
1259 {
1260 Ok(sf) => {
1261 return Ok(ResolvedOperation {
1262 is_async: sf.has_stop || sf.has_request_results,
1263 operation: DiagComm {
1264 name: service.to_owned(),
1265 type_: DiagCommType::Operations,
1266 lookup_name: None,
1267 subfunction_id: Some(subfunction_ids::routine::START),
1268 },
1269 });
1270 }
1271 Err(DiagServiceError::NotFound(_)) => {}
1272 Err(error) => return Err(ApiError::from(error)),
1273 }
1274
1275 match uds
1276 .get_io_control_service(ecu_name, service, security_plugin)
1277 .await
1278 {
1279 Ok(operation) => Ok(ResolvedOperation {
1280 is_async: false,
1281 operation,
1282 }),
1283 Err(DiagServiceError::NotFound(_)) => Err(ApiError::from(
1284 DiagServiceError::NotFound(format!("Operation '{service}' not found")),
1285 )),
1286 Err(error) => Err(ApiError::from(error)),
1287 }
1288 }
1289
1290 /// Sends the Start subfunction request and returns the positive response, or
1291 /// `Err(Response)` if the UDS call failed, returned a negative response, or
1292 /// was cancelled because the owning lock is closing.
1293 async fn send_start_request<T: UdsEcu>(
1294 uds: &T,
1295 ecu_name: &str,
1296 diag_service: DiagComm,
1297 security_plugin: &DynamicPlugin,
1298 data: Option<cda_interfaces::diagservices::UdsPayloadData>,
1299 map_to_json: bool,
1300 include_schema: bool,
1301 ) -> Result<Option<T::Response>, Box<Response>> {
1302 let response = match uds
1303 .send(ecu_name, diag_service, security_plugin, data, map_to_json)
1304 .await
1305 {
1306 Ok(v) => v,
1307 Err(e) => {
1308 return Err(Box::new(
1309 ErrorWrapper {
1310 error: e.into(),
1311 include_schema,
1312 }
1313 .into_response(),
1314 ));
1315 }
1316 };
1317 if let DiagServiceResponseType::Negative = response.response_type() {
1318 return Err(Box::new(
1319 api_error_from_diag_response(&response, include_schema).into_response(),
1320 ));
1321 }
1322 Ok(Some(response))
1323 }
1324
1325 fn err_invalid_content(
1326 detail: String,
1327 ) -> sovd_interfaces::error::DataError<VendorErrorCode> {
1328 sovd_interfaces::error::DataError {
1329 path: "/parameters".to_owned(),
1330 error: sovd_interfaces::error::ApiErrorResponse {
1331 message: detail,
1332 error_code: sovd_interfaces::error::ErrorCode::InvalidResponseContent,
1333 vendor_code: None,
1334 parameters: None,
1335 error_source: None,
1336 schema: None,
1337 },
1338 }
1339 }
1340
1341 /// Parses a positive, non-empty UDS response into `(parameters, errors)`.
1342 ///
1343 /// - `Object` -> parameters map + any field-parse errors
1344 /// - `Null` -> empty parameters, no errors (ECU signalled no output)
1345 /// - anything else, or a parse failure -> empty parameters, one soft `DataError`
1346 fn parse_json_response_params<R: DiagServiceResponse>(
1347 response: R,
1348 context: &str,
1349 ) -> (
1350 serde_json::Map<String, serde_json::Value>,
1351 Vec<sovd_interfaces::error::DataError<VendorErrorCode>>,
1352 ) {
1353 match response.into_json() {
1354 Ok(DiagServiceJsonResponse {
1355 data: serde_json::Value::Object(m),
1356 errors,
1357 }) => (m, field_parse_errors_to_json(errors, "parameters")),
1358 Ok(DiagServiceJsonResponse {
1359 data: serde_json::Value::Null,
1360 ..
1361 }) => (serde_json::Map::new(), vec![]),
1362 Ok(v) => (
1363 serde_json::Map::new(),
1364 vec![err_invalid_content(format!(
1365 "Expected JSON object but got: {}",
1366 v.data
1367 ))],
1368 ),
1369 Err(e) => (
1370 serde_json::Map::new(),
1371 vec![err_invalid_content(format!(
1372 "Failed to parse {context} response: {e:?}"
1373 ))],
1374 ),
1375 }
1376 }
1377
1378 fn parse_exec_uuid(id: &str, include_schema: bool) -> Result<Uuid, ErrorWrapper> {
1379 Uuid::parse_str(id).map_err(|e| ErrorWrapper {
1380 error: ApiError::BadRequest(format!("{e:?}")),
1381 include_schema,
1382 })
1383 }
1384
1385 /// Builds the `200 OK` `AsyncGetByIdResponse` body used by both the
1386 /// `RequestResults` success path and the `suppress_service` fallback path.
1387 /// An empty `parameters` map is serialised as `null` per the SOVD spec.
1388 fn get_by_id_response(
1389 status: ExecutionStatus,
1390 parameters: serde_json::Map<String, serde_json::Value>,
1391 error: Vec<sovd_interfaces::error::DataError<VendorErrorCode>>,
1392 include_schema: bool,
1393 ) -> Response {
1394 use sovd_interfaces::components::ecu::operations::GetByIdCapability;
1395 let schema = if include_schema {
1396 Some(create_schema!(AsyncGetByIdResponse<VendorErrorCode>))
1397 } else {
1398 None
1399 };
1400 let parameters = if parameters.is_empty() {
1401 None
1402 } else {
1403 Some(parameters)
1404 };
1405 (
1406 StatusCode::OK,
1407 Json(AsyncGetByIdResponse::<VendorErrorCode> {
1408 status,
1409 capability: GetByIdCapability::Execute,
1410 parameters,
1411 progress: None,
1412 error,
1413 schema,
1414 }),
1415 )
1416 .into_response()
1417 }
1418
1419 /// Handles the async (Stop/RequestResults) POST path: finalises the
1420 /// previously reserved execution with the Start-response parameters,
1421 /// then returns them with the execution id and status.
1422 fn handle_async_post<T: UdsEcu>(
1423 response: Option<T::Response>,
1424 include_schema: bool,
1425 base_path: &str,
1426 reservation: ExecutionReservation<ServiceExecution>,
1427 ) -> Response {
1428 let (parameters, errors) = match response {
1429 Some(r) if !r.is_empty() => parse_json_response_params(r, "Start"),
1430 _ => (serde_json::Map::new(), vec![]),
1431 };
1432 let stored_parameters = parameters.clone();
1433 let exec_id = reservation.commit_async(|exec| {
1434 exec.parameters = stored_parameters;
1435 });
1436 let schema = if include_schema {
1437 Some(create_schema!(
1438 AsyncPostResponse<serde_json::Map<String, serde_json::Value>, VendorErrorCode>
1439 ))
1440 } else {
1441 None
1442 };
1443 (
1444 StatusCode::ACCEPTED,
1445 [(header::LOCATION, format!("{base_path}/{exec_id}"))],
1446 Json(AsyncPostResponse {
1447 id: exec_id.to_string(),
1448 status: Some(ExecutionStatus::Running),
1449 parameters,
1450 errors,
1451 schema,
1452 }),
1453 )
1454 .into_response()
1455 }
1456
1457 /// Handles the synchronous (no Stop/RequestResults) POST path: returns 200 OK
1458 /// with the mapped response parameters, or raw bytes if the accept header
1459 /// requested octet-stream.
1460 async fn handle_sync_post<T: UdsEcu + SchemaProvider>(
1461 response: Option<T::Response>,
1462 map_to_json: bool,
1463 include_schema: bool,
1464 ecu_name: &str,
1465 uds: &T,
1466 diag_service: &DiagComm,
1467 ) -> Response {
1468 let schema = if map_to_json && include_schema {
1469 let subschema = get_subschema(ecu_name, uds, diag_service).await;
1470 Some(create_response_schema!(
1471 sovd_executions::Response<VendorErrorCode>,
1472 "parameters",
1473 subschema
1474 ))
1475 } else {
1476 None
1477 };
1478
1479 if map_to_json {
1480 let (mapped_data, parse_errors) =
1481 match response.and_then(|r| (!r.is_empty()).then(|| r.into_json())) {
1482 None => (serde_json::Map::new(), vec![]),
1483 Some(Ok(DiagServiceJsonResponse {
1484 data: serde_json::Value::Object(mapped_data),
1485 errors,
1486 })) => (mapped_data, errors),
1487 Some(Ok(v)) => {
1488 return ErrorWrapper {
1489 error: ApiError::InternalServerError(Some(format!(
1490 "Expected JSON object but got: {}",
1491 v.data
1492 ))),
1493 include_schema,
1494 }
1495 .into_response();
1496 }
1497 Some(Err(e)) => {
1498 return ErrorWrapper {
1499 error: ApiError::InternalServerError(Some(format!("{e:?}"))),
1500 include_schema,
1501 }
1502 .into_response();
1503 }
1504 };
1505 // Spec Table 183: `error` is singular (first parse error wins).
1506 let error = field_parse_errors_to_json(parse_errors, "parameters")
1507 .into_iter()
1508 .next();
1509 let parameters = if mapped_data.is_empty() {
1510 None
1511 } else {
1512 Some(mapped_data)
1513 };
1514 (
1515 StatusCode::OK,
1516 Json(sovd_executions::Response {
1517 parameters,
1518 error,
1519 schema,
1520 }),
1521 )
1522 .into_response()
1523 } else {
1524 let data = response.map_or(vec![], |r| r.get_raw().to_vec());
1525 (StatusCode::OK, Bytes::from_owner(data)).into_response()
1526 }
1527 }
1528
1529 #[allow(
1530 clippy::too_many_lines,
1531 reason = "Current implementation has little potential to extract smaller functions"
1532 )]
1533 async fn ecu_reset_handler<T: UdsEcu + SchemaProvider + Clone>(
1534 service: String,
1535 ecu_name: &str,
1536 uds: &T,
1537 body: Bytes,
1538 security_plugin: Box<dyn SecurityPlugin>,
1539 include_schema: bool,
1540 ) -> Response {
1541 // todo: in the future we have to handle possible parameters for the reset service
1542 let Some(request_parameters) =
1543 serde_json::from_slice::<sovd_executions::Request>(&body)
1544 .ok()
1545 .and_then(|v| v.parameters)
1546 else {
1547 return ErrorWrapper {
1548 error: ApiError::BadRequest("Invalid request body".to_string()),
1549 include_schema,
1550 }
1551 .into_response();
1552 };
1553
1554 let Some(value) = request_parameters.get("value") else {
1555 return ErrorWrapper {
1556 error: ApiError::BadRequest(
1557 "Missing 'value' parameter in request body".to_owned(),
1558 ),
1559 include_schema,
1560 }
1561 .into_response();
1562 };
1563
1564 let Some(value_str) = value.as_str() else {
1565 return ErrorWrapper {
1566 error: ApiError::BadRequest(
1567 "The 'value' parameter must be a string".to_owned(),
1568 ),
1569 include_schema,
1570 }
1571 .into_response();
1572 };
1573
1574 let allowed_values = uds.get_ecu_reset_services(ecu_name).await;
1575 let allowed_values = match allowed_values {
1576 Ok(v) => v,
1577 Err(e) => {
1578 return ErrorWrapper {
1579 error: e.into(),
1580 include_schema,
1581 }
1582 .into_response();
1583 }
1584 };
1585
1586 if !allowed_values
1587 .iter()
1588 .any(|v| v.eq_ignore_ascii_case(value_str))
1589 {
1590 return ErrorWrapper {
1591 error: ApiError::BadRequest(format!(
1592 "Invalid value for reset service: {value_str}. Allowed values: [{}]",
1593 allowed_values.join(", ")
1594 )),
1595 include_schema,
1596 }
1597 .into_response();
1598 }
1599
1600 let diag_service = DiagComm {
1601 name: service.clone(),
1602 type_: DiagCommType::Modes, // ecureset is in modes
1603 lookup_name: Some(value_str.to_owned()),
1604 subfunction_id: None,
1605 };
1606
1607 let schema = if include_schema {
1608 let subschema = get_subschema(ecu_name, uds, &diag_service).await;
1609 Some(create_response_schema!(
1610 sovd_executions::Response<VendorErrorCode>,
1611 "parameters",
1612 subschema
1613 ))
1614 } else {
1615 None
1616 };
1617
1618 let security_plugin: DynamicPlugin = security_plugin;
1619 let send_result = uds
1620 .send(ecu_name, diag_service, &security_plugin, None, true)
1621 .await;
1622 let response = match send_result {
1623 Ok(v) => v,
1624 Err(e) => {
1625 return ErrorWrapper {
1626 error: e.into(),
1627 include_schema,
1628 }
1629 .into_response();
1630 }
1631 };
1632
1633 match response.response_type() {
1634 DiagServiceResponseType::Negative => {
1635 api_error_from_diag_response(&response, include_schema).into_response()
1636 }
1637 DiagServiceResponseType::Positive => {
1638 if response.is_empty() {
1639 StatusCode::NO_CONTENT.into_response()
1640 } else {
1641 let (response_data, parse_errors) = match response.into_json() {
1642 Ok(DiagServiceJsonResponse {
1643 data: serde_json::Value::Object(mapped_data),
1644 errors,
1645 }) => (mapped_data, errors),
1646 Ok(DiagServiceJsonResponse {
1647 data: serde_json::Value::Null,
1648 errors,
1649 }) => {
1650 if errors.is_empty() {
1651 return StatusCode::NO_CONTENT.into_response();
1652 }
1653 (serde_json::Map::new(), errors)
1654 }
1655 Ok(v) => {
1656 return ErrorWrapper {
1657 error: ApiError::InternalServerError(Some(format!(
1658 "Expected JSON object but got: {}",
1659 v.data
1660 ))),
1661 include_schema,
1662 }
1663 .into_response();
1664 }
1665 Err(e) => {
1666 return ErrorWrapper {
1667 error: ApiError::InternalServerError(Some(format!("{e:?}"))),
1668 include_schema,
1669 }
1670 .into_response();
1671 }
1672 };
1673 let error = field_parse_errors_to_json(parse_errors, "parameters")
1674 .into_iter()
1675 .next();
1676 let parameters = if response_data.is_empty() {
1677 None
1678 } else {
1679 Some(response_data)
1680 };
1681 (
1682 StatusCode::OK,
1683 Json(sovd_executions::Response {
1684 parameters,
1685 error,
1686 schema,
1687 }),
1688 )
1689 .into_response()
1690 }
1691 }
1692 }
1693 }
1694
1695 async fn get_subschema<T: SchemaProvider>(
1696 ecu_name: &str,
1697 uds: &T,
1698 diag_service: &DiagComm,
1699 ) -> Option<schemars::Schema> {
1700 match uds
1701 .schema_for_responses(ecu_name, diag_service)
1702 .await
1703 .map(cda_interfaces::SchemaDescription::into_schema)
1704 {
1705 Ok(v) => v,
1706 Err(e) => {
1707 tracing::error!(
1708 error = ?e,
1709 diag_service = ?diag_service,
1710 "Failed to get schema for diag service"
1711 );
1712 None
1713 }
1714 }
1715 }
1716
1717 pub(crate) mod id {
1718 use super::*;
1719 use crate::sovd::locks::require_ecu_access;
1720
1721 #[derive(serde::Deserialize, schemars::JsonSchema)]
1722 pub(crate) struct ServiceAndIdPathParam {
1723 pub service: String,
1724 pub id: String,
1725 }
1726
1727 /// Process a successful UDS `RequestResults` response: parse it, update stored
1728 /// execution state, and return the `200 OK` response body.
1729 fn get_operations_response<T: UdsEcu>(
1730 response: T::Response,
1731 service: &str,
1732 exec_id: Uuid,
1733 service_executions: &std::sync::RwLock<
1734 cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
1735 >,
1736 include_schema: bool,
1737 ) -> Response {
1738 if let DiagServiceResponseType::Negative = response.response_type() {
1739 tracing::warn!(
1740 exec_id = %exec_id,
1741 "RequestResults subfunction returned negative response"
1742 );
1743 return api_error_from_diag_response(&response, include_schema).into_response();
1744 }
1745
1746 let (parameters, error_list) = if response.is_empty() {
1747 (serde_json::Map::new(), vec![])
1748 } else {
1749 parse_json_response_params::<T::Response>(response, "RequestResults")
1750 };
1751
1752 // Copied before the lock is taken: the stored execution and the response body
1753 // both need the parameters, so the deep copy happens outside the critical
1754 // section rather than under the write lock.
1755 let stored_parameters = parameters.clone();
1756 if let Some(stored_mut) = lock_write(service_executions)
1757 .get_mut(service)
1758 .and_then(|m| m.get_mut(&exec_id))
1759 {
1760 stored_mut.parameters = stored_parameters;
1761 stored_mut.complete();
1762 }
1763
1764 get_by_id_response(
1765 ExecutionStatus::Completed,
1766 parameters,
1767 error_list,
1768 include_schema,
1769 )
1770 }
1771
1772 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
1773 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
1774 Path(ServiceAndIdPathParam { service, id }): Path<ServiceAndIdPathParam>,
1775 WithRejection(Query(query), _): WithRejection<Query<OperationQuery>, ApiError>,
1776 State(WebserverEcuState {
1777 ecu_name,
1778 uds,
1779 locks,
1780 service_executions,
1781 ..
1782 }): State<WebserverEcuState<T, U>>,
1783 ) -> Response {
1784 let include_schema = query.include_schema;
1785 require_ecu_access!(write, security_plugin, &ecu_name, &locks, include_schema);
1786 let exec_id = match parse_exec_uuid(&id, include_schema) {
1787 Ok(v) => v,
1788 Err(e) => return e.into_response(),
1789 };
1790
1791 let stored = match guard_execution(
1792 &service_executions,
1793 &service,
1794 exec_id,
1795 include_schema,
1796 &format!("Execution {exec_id} is already in progress"),
1797 ) {
1798 Ok(guard) => guard,
1799 Err(error) => return error.into_response(),
1800 };
1801
1802 // suppress_service: skip the UDS send, return stored state directly
1803 if query.suppress_service {
1804 return get_by_id_response(
1805 stored.snapshot().status.clone(),
1806 stored.snapshot().parameters.clone(),
1807 vec![],
1808 include_schema,
1809 );
1810 }
1811
1812 // Try to send RequestResults (subfunction 0x03)
1813 let diag_service = DiagComm {
1814 name: service.clone(),
1815 type_: DiagCommType::Operations,
1816 lookup_name: None,
1817 subfunction_id: Some(subfunction_ids::routine::REQUEST_RESULTS),
1818 };
1819 let uds_result = uds
1820 .send(
1821 &ecu_name,
1822 diag_service,
1823 &(security_plugin as DynamicPlugin),
1824 None,
1825 true,
1826 )
1827 .await;
1828
1829 match uds_result {
1830 Err(e) => {
1831 tracing::warn!(
1832 error = ?e,
1833 service = %service,
1834 exec_id = %exec_id,
1835 "RequestResults subfunction failed"
1836 );
1837 ErrorWrapper {
1838 error: e.into(),
1839 include_schema,
1840 }
1841 .into_response()
1842 }
1843 Ok(response) => get_operations_response::<T>(
1844 response,
1845 &service,
1846 exec_id,
1847 &service_executions,
1848 include_schema,
1849 ),
1850 }
1851 }
1852
1853 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
1854 op.description(
1855 "Get the result of an async operation execution (RequestResults subfunction)",
1856 )
1857 .response_with::<200, Json<AsyncGetByIdResponse<VendorErrorCode>>, _>(|res| {
1858 res.description("Execution result retrieved successfully.")
1859 })
1860 .with(openapi::error_not_found)
1861 .with(openapi::error_bad_request)
1862 .with(openapi::error_bad_gateway)
1863 }
1864
1865 #[allow(
1866 clippy::too_many_lines,
1867 reason = "Keeping validation, execution guarding, UDS Stop, and guard cleanup \
1868 together makes cleanup on every response path visible. Splitting the \
1869 flow would obscure that state invariant"
1870 )]
1871 pub(crate) async fn delete<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
1872 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
1873 Path(ServiceAndIdPathParam { service, id }): Path<ServiceAndIdPathParam>,
1874 WithRejection(Query(query), _): WithRejection<
1875 Query<OperationDeleteQuery>,
1876 ApiError,
1877 >,
1878 State(WebserverEcuState {
1879 ecu_name,
1880 uds,
1881 locks,
1882 service_executions,
1883 ..
1884 }): State<WebserverEcuState<T, U>>,
1885 ) -> Response {
1886 let include_schema = query.include_schema;
1887 require_ecu_access!(write, security_plugin, &ecu_name, &locks, include_schema);
1888 let exec_id = match parse_exec_uuid(&id, include_schema) {
1889 Ok(v) => v,
1890 Err(e) => return e.into_response(),
1891 };
1892
1893 let _request_guard = match guard_execution(
1894 &service_executions,
1895 &service,
1896 exec_id,
1897 include_schema,
1898 &format!("Execution {exec_id} is already being stopped"),
1899 ) {
1900 Ok(guard) => guard,
1901 Err(error) => return error.into_response(),
1902 };
1903
1904 let diag_service = DiagComm {
1905 name: service.clone(),
1906 type_: DiagCommType::Operations,
1907 lookup_name: None,
1908 subfunction_id: Some(subfunction_ids::routine::STOP),
1909 };
1910 let uds_result = uds
1911 .send(
1912 &ecu_name,
1913 diag_service,
1914 &(security_plugin as DynamicPlugin),
1915 None,
1916 true,
1917 )
1918 .await;
1919
1920 match uds_result {
1921 Ok(r) if matches!(r.response_type(), DiagServiceResponseType::Positive) => {
1922 if let Some(op_map) = lock_write(&service_executions).get_mut(&service) {
1923 op_map.shift_remove(&exec_id);
1924 }
1925 if r.is_empty() {
1926 StatusCode::NO_CONTENT.into_response()
1927 } else {
1928 let (parameters, error_list) =
1929 parse_json_response_params::<T::Response>(r, "Stop");
1930 get_by_id_response(
1931 ExecutionStatus::Stopped,
1932 parameters,
1933 error_list,
1934 include_schema,
1935 )
1936 }
1937 }
1938 Err(cda_interfaces::DiagServiceError::NotFound(_))
1939 if query.suppress_service =>
1940 {
1941 tracing::warn!(
1942 service = %service,
1943 exec_id = %exec_id,
1944 "Stop service not found (suppress_service=true), removing execution"
1945 );
1946 if let Some(op_map) = lock_write(&service_executions).get_mut(&service) {
1947 op_map.shift_remove(&exec_id);
1948 }
1949 StatusCode::NO_CONTENT.into_response()
1950 }
1951 result => {
1952 if let Err(e) = &result {
1953 tracing::warn!(
1954 error = ?e,
1955 service = %service,
1956 exec_id = %exec_id,
1957 "Stop subfunction failed"
1958 );
1959 } else {
1960 tracing::warn!(
1961 service = %service,
1962 exec_id = %exec_id,
1963 "Stop subfunction returned negative response"
1964 );
1965 }
1966 if query.force {
1967 if let Some(op_map) = lock_write(&service_executions).get_mut(&service)
1968 {
1969 op_map.shift_remove(&exec_id);
1970 }
1971 return StatusCode::NO_CONTENT.into_response();
1972 }
1973
1974 match result {
1975 Err(e) => ErrorWrapper {
1976 error: e.into(),
1977 include_schema,
1978 }
1979 .into_response(),
1980 Ok(r) => {
1981 api_error_from_diag_response(&r, include_schema).into_response()
1982 }
1983 }
1984 }
1985 }
1986 }
1987
1988 pub(crate) fn docs_delete(op: TransformOperation) -> TransformOperation {
1989 op.description(
1990 "Stop an async operation execution (Stop subfunction). Use \
1991 ?x-sovd2uds-force=true to remove even on ECU error.",
1992 )
1993 .response_with::<204, (), _>(|res| {
1994 res.description("Execution stopped and removed (no response data from ECU).")
1995 })
1996 .response_with::<200, Json<AsyncGetByIdResponse<VendorErrorCode>>, _>(|res| {
1997 res.description(
1998 "Execution stopped and removed. The ECU returned response data (non-spec \
1999 extension).",
2000 )
2001 })
2002 .with(openapi::error_not_found)
2003 .with(openapi::error_bad_request)
2004 .with(openapi::error_bad_gateway)
2005 .with(openapi::error_forbidden)
2006 }
2007 }
2008 }
2009}
2010
2011#[cfg(test)]
2012mod tests {
2013 use sovd_interfaces::components::ecu::operations::comparams::ComParamSimpleValue;
2014
2015 mod comparam_execution_authorization {
2016 use std::sync::Arc;
2017
2018 use aide::UseApi;
2019 use axum::extract::{OriginalUri, Path, Query, State};
2020 use axum_extra::extract::WithRejection;
2021 use cda_interfaces::{
2022 HashMap, HashMapExtensions, file_manager::mock::MockFileManager, mock::MockUdsEcu,
2023 };
2024 use cda_plugin_security::{
2025 Secured, SecurityPlugin,
2026 mock::{MockAuthApi, MockClaims, MockSecurityPlugin, TestSecurityPlugin},
2027 };
2028 use opensovd_axum_extra::ExtractHost;
2029 use sovd_interfaces::components::ecu::operations::comparams::{self, executions};
2030 use uuid::Uuid;
2031
2032 use super::super::comparams::executions as handlers;
2033 use crate::sovd::{
2034 components::IdPathParam, locks::insert_test_ecu_lock,
2035 tests::create_test_webserver_state,
2036 };
2037
2038 fn foreign_security_plugin() -> Box<dyn SecurityPlugin> {
2039 let mut claims = MockClaims::new();
2040 claims.expect_sub().return_const("foreign_user");
2041 let claims: &'static dyn cda_plugin_security::Claims = Box::leak(Box::new(claims));
2042 let mut auth = MockAuthApi::new();
2043 auth.expect_claims().return_const(Box::new(claims));
2044 let mut plugin = MockSecurityPlugin::new();
2045 plugin
2046 .expect_as_auth_plugin()
2047 .return_const(Box::new(auth) as Box<dyn cda_plugin_security::AuthApi>);
2048 Box::new(plugin)
2049 }
2050
2051 fn query() -> WithRejection<Query<executions::get::Query>, crate::sovd::error::ApiError> {
2052 WithRejection(
2053 Query(sovd_interfaces::IncludeSchemaQuery {
2054 include_schema: false,
2055 }),
2056 std::marker::PhantomData,
2057 )
2058 }
2059
2060 fn request() -> executions::update::Request {
2061 executions::update::Request {
2062 capability: None,
2063 timeout: None,
2064 parameters: Some(HashMap::from_iter([(
2065 "P2ServerMax".to_owned(),
2066 comparams::ComParamValue::Simple(comparams::ComParamSimpleValue {
2067 value: "100".to_owned(),
2068 unit: None,
2069 }),
2070 )])),
2071 proximity_response: None,
2072 }
2073 }
2074
2075 fn execution(
2076 communication_access: &dyn cda_interfaces::communication_control::CommunicationAccess,
2077 ) -> crate::sovd::ComparamExecution {
2078 crate::sovd::ComparamExecution::new(
2079 comparams::Execution {
2080 capability: executions::Capability::Execute,
2081 status: executions::Status::Running,
2082 comparam_override: HashMap::new(),
2083 },
2084 communication_access
2085 .acquire()
2086 .expect("Test communication must be enabled"),
2087 )
2088 }
2089
2090 #[tokio::test]
2091 async fn get_foreign_exclusive_lock_skips_uds() {
2092 let mut uds = MockUdsEcu::new();
2093 uds.expect_get_comparams().times(0);
2094 let state =
2095 create_test_webserver_state("TestECU".to_owned(), uds, MockFileManager::new());
2096 insert_test_ecu_lock(&state.locks, "TestECU").await;
2097 let id = Uuid::new_v4();
2098 state
2099 .comparam_executions
2100 .write()
2101 .await
2102 .insert(id, execution(&*state.communication_access));
2103 let executions = Arc::clone(&state.comparam_executions);
2104
2105 let response = handlers::id::get::<MockUdsEcu, MockFileManager>(
2106 UseApi(Secured(foreign_security_plugin()), std::marker::PhantomData),
2107 Path(IdPathParam { id: id.to_string() }),
2108 query(),
2109 State(state),
2110 )
2111 .await;
2112
2113 assert_eq!(response.status(), http::StatusCode::LOCKED);
2114 assert!(executions.read().await.contains_key(&id));
2115 }
2116
2117 #[tokio::test]
2118 async fn post_without_owned_write_lock_does_not_create_execution() {
2119 let state = create_test_webserver_state(
2120 "TestECU".to_owned(),
2121 MockUdsEcu::new(),
2122 MockFileManager::new(),
2123 );
2124 let executions = Arc::clone(&state.comparam_executions);
2125
2126 let response = handlers::post::<MockUdsEcu, MockFileManager>(
2127 UseApi(
2128 Secured(Box::new(TestSecurityPlugin)),
2129 std::marker::PhantomData,
2130 ),
2131 query(),
2132 State(state),
2133 UseApi(
2134 ExtractHost("localhost".to_owned()),
2135 std::marker::PhantomData,
2136 ),
2137 OriginalUri(
2138 "/components/TestECU/operations/comparams/executions"
2139 .parse()
2140 .unwrap(),
2141 ),
2142 None,
2143 )
2144 .await;
2145
2146 assert_eq!(response.status(), http::StatusCode::CONFLICT);
2147 assert!(executions.read().await.is_empty());
2148 }
2149
2150 #[tokio::test]
2151 async fn put_without_owned_write_lock_does_not_mutate_execution() {
2152 let state = create_test_webserver_state(
2153 "TestECU".to_owned(),
2154 MockUdsEcu::new(),
2155 MockFileManager::new(),
2156 );
2157 let id = Uuid::new_v4();
2158 state
2159 .comparam_executions
2160 .write()
2161 .await
2162 .insert(id, execution(&*state.communication_access));
2163 let executions = Arc::clone(&state.comparam_executions);
2164
2165 let response = handlers::id::put::<MockUdsEcu, MockFileManager>(
2166 UseApi(
2167 Secured(Box::new(TestSecurityPlugin)),
2168 std::marker::PhantomData,
2169 ),
2170 Path(IdPathParam { id: id.to_string() }),
2171 query(),
2172 State(state),
2173 UseApi(
2174 ExtractHost("localhost".to_owned()),
2175 std::marker::PhantomData,
2176 ),
2177 OriginalUri(
2178 format!("/components/TestECU/operations/comparams/executions/{id}")
2179 .parse()
2180 .unwrap(),
2181 ),
2182 WithRejection(axum::Json(request()), std::marker::PhantomData),
2183 )
2184 .await;
2185
2186 assert_eq!(response.status(), http::StatusCode::CONFLICT);
2187 let executions = executions.read().await;
2188 assert!(
2189 executions
2190 .get(&id)
2191 .is_some_and(|execution| execution.comparam_override.is_empty())
2192 );
2193 }
2194
2195 #[tokio::test]
2196 async fn delete_without_owned_write_lock_does_not_remove_execution() {
2197 let state = create_test_webserver_state(
2198 "TestECU".to_owned(),
2199 MockUdsEcu::new(),
2200 MockFileManager::new(),
2201 );
2202 let id = Uuid::new_v4();
2203 state
2204 .comparam_executions
2205 .write()
2206 .await
2207 .insert(id, execution(&*state.communication_access));
2208 let executions = Arc::clone(&state.comparam_executions);
2209
2210 let response = handlers::id::delete::<MockUdsEcu, MockFileManager>(
2211 UseApi(
2212 Secured(Box::new(TestSecurityPlugin)),
2213 std::marker::PhantomData,
2214 ),
2215 Path(IdPathParam { id: id.to_string() }),
2216 State(state),
2217 )
2218 .await;
2219
2220 assert_eq!(response.status(), http::StatusCode::CONFLICT);
2221 assert!(executions.read().await.contains_key(&id));
2222 }
2223
2224 #[tokio::test]
2225 async fn post_with_owned_write_lock_creates_execution() {
2226 let state = create_test_webserver_state(
2227 "TestECU".to_owned(),
2228 MockUdsEcu::new(),
2229 MockFileManager::new(),
2230 );
2231 insert_test_ecu_lock(&state.locks, "TestECU").await;
2232 let executions = Arc::clone(&state.comparam_executions);
2233
2234 let response = handlers::post::<MockUdsEcu, MockFileManager>(
2235 UseApi(
2236 Secured(Box::new(TestSecurityPlugin)),
2237 std::marker::PhantomData,
2238 ),
2239 query(),
2240 State(state),
2241 UseApi(
2242 ExtractHost("localhost".to_owned()),
2243 std::marker::PhantomData,
2244 ),
2245 OriginalUri(
2246 "/components/TestECU/operations/comparams/executions"
2247 .parse()
2248 .unwrap(),
2249 ),
2250 Some(axum::Json(request())),
2251 )
2252 .await;
2253
2254 assert_eq!(response.status(), http::StatusCode::ACCEPTED);
2255 assert_eq!(executions.read().await.len(), 1);
2256 }
2257 }
2258
2259 #[test]
2260 #[allow(clippy::float_cmp, reason = "Test verifies exact deserialized values")]
2261 fn com_param_simple_deserialization() {
2262 let json_data_string = "\"example_value\"";
2263 let deserialized_string: ComParamSimpleValue =
2264 serde_json::from_str(json_data_string).unwrap();
2265 assert_eq!(deserialized_string.value, "example_value");
2266 assert!(deserialized_string.unit.is_none());
2267
2268 let json_data_struct = r#"{
2269 "value": "test",
2270 "unit": {
2271 "factor_to_si_unit": 1.0,
2272 "offset_to_si_unit": 0.0
2273 }
2274 }"#;
2275 let deserialized_struct: ComParamSimpleValue =
2276 serde_json::from_str(json_data_struct).unwrap();
2277 assert_eq!(deserialized_struct.value, "test");
2278 let unit = deserialized_struct.unit.unwrap();
2279 assert_eq!(unit.factor_to_si_unit.unwrap(), 1.0);
2280 assert_eq!(unit.offset_to_si_unit.unwrap(), 0.0);
2281
2282 let json_data_struct = r#"{
2283 "value": "test",
2284 "unit": {
2285 "factor_to_si_unit": 1.0
2286 }
2287 }"#;
2288 let deserialized_struct: ComParamSimpleValue =
2289 serde_json::from_str(json_data_struct).unwrap();
2290 assert_eq!(deserialized_struct.value, "test");
2291 let unit = deserialized_struct.unit.unwrap();
2292 assert_eq!(unit.factor_to_si_unit.unwrap(), 1.0);
2293 assert!(unit.offset_to_si_unit.is_none());
2294
2295 let json_data_struct = r#"{"value": "test"}"#;
2296 let deserialized_struct: ComParamSimpleValue =
2297 serde_json::from_str(json_data_struct).unwrap();
2298 assert_eq!(deserialized_struct.value, "test");
2299 assert!(deserialized_struct.unit.is_none());
2300
2301 let json_data_struct = r#"{"unit": {"factor_to_si_unit": 1.0}}"#;
2302 assert!(serde_json::from_str::<ComParamSimpleValue>(json_data_struct).is_err());
2303 }
2304
2305 mod ecu_operations_collection {
2306 use aide::UseApi;
2307 use axum::{extract::State, http::StatusCode};
2308 use axum_extra::extract::WithRejection;
2309 use cda_interfaces::{
2310 datatypes::ComponentOperationsInfo,
2311 file_manager::mock::MockFileManager,
2312 mock::{MockUdsEcu, mock_ecu_state_online_variant_detected},
2313 };
2314 use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
2315 use sovd_interfaces::components::ecu::operations::OperationCollectionItem;
2316
2317 use super::super::*;
2318 use crate::sovd::tests::create_test_webserver_state;
2319
2320 #[tokio::test]
2321 async fn test_get_operations_returns_empty_list() {
2322 let ecu_name = "TestECU".to_string();
2323 let mut mock_uds = MockUdsEcu::new();
2324 let mock_file_manager = MockFileManager::new();
2325
2326 mock_uds
2327 .expect_get_ecu_state()
2328 .returning(|_| Ok(mock_ecu_state_online_variant_detected()));
2329 mock_uds
2330 .expect_get_components_operations_info()
2331 .withf(|ecu, _| ecu == "TestECU")
2332 .times(1)
2333 .returning(|_, _| Ok(vec![]));
2334
2335 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2336 ecu_name,
2337 mock_uds,
2338 mock_file_manager,
2339 );
2340
2341 let response = get::<MockUdsEcu, MockFileManager>(
2342 UseApi(
2343 Secured(Box::new(TestSecurityPlugin)),
2344 std::marker::PhantomData,
2345 ),
2346 WithRejection(
2347 Query(sovd_interfaces::IncludeSchemaQuery {
2348 include_schema: false,
2349 }),
2350 std::marker::PhantomData,
2351 ),
2352 State(state),
2353 )
2354 .await;
2355
2356 assert_eq!(response.status(), StatusCode::OK);
2357 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2358 .await
2359 .unwrap();
2360 let result: sovd_interfaces::Items<OperationCollectionItem> =
2361 serde_json::from_slice(&body).unwrap();
2362 assert!(result.items.is_empty());
2363 assert!(result.schema.is_none());
2364 }
2365
2366 #[tokio::test]
2367 async fn test_get_operations_returns_items() {
2368 let ecu_name = "TestECU".to_string();
2369 let mut mock_uds = MockUdsEcu::new();
2370 let mock_file_manager = MockFileManager::new();
2371
2372 mock_uds
2373 .expect_get_ecu_state()
2374 .returning(|_| Ok(mock_ecu_state_online_variant_detected()));
2375 mock_uds
2376 .expect_get_components_operations_info()
2377 .withf(|ecu, _| ecu == "TestECU")
2378 .times(1)
2379 .returning(|_, _| {
2380 Ok(vec![
2381 ComponentOperationsInfo {
2382 id: "CalibrateSensor".to_string(),
2383 name: "Calibrate Sensor".to_string(),
2384 has_stop: true,
2385 has_request_results: true,
2386 },
2387 ComponentOperationsInfo {
2388 id: "RunSelfTest".to_string(),
2389 name: "Run Self Test".to_string(),
2390 has_stop: false,
2391 has_request_results: false,
2392 },
2393 ])
2394 });
2395
2396 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2397 ecu_name,
2398 mock_uds,
2399 mock_file_manager,
2400 );
2401
2402 let response = get::<MockUdsEcu, MockFileManager>(
2403 UseApi(
2404 Secured(Box::new(TestSecurityPlugin)),
2405 std::marker::PhantomData,
2406 ),
2407 WithRejection(
2408 Query(sovd_interfaces::IncludeSchemaQuery {
2409 include_schema: false,
2410 }),
2411 std::marker::PhantomData,
2412 ),
2413 State(state),
2414 )
2415 .await;
2416
2417 assert_eq!(response.status(), StatusCode::OK);
2418 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2419 .await
2420 .unwrap();
2421 let result: sovd_interfaces::Items<OperationCollectionItem> =
2422 serde_json::from_slice(&body).unwrap();
2423 assert_eq!(result.items.len(), 2);
2424
2425 let first = result.items.first().expect("Expected at least one item");
2426 assert_eq!(first.id, "CalibrateSensor");
2427 assert_eq!(first.name, "Calibrate Sensor");
2428 assert!(!first.proximity_proof_required);
2429 assert!(first.asynchronous_execution);
2430
2431 let second = result.items.get(1).expect("Expected a second item");
2432 assert_eq!(second.id, "RunSelfTest");
2433 assert!(!second.proximity_proof_required);
2434 assert!(!second.asynchronous_execution);
2435 }
2436
2437 #[tokio::test]
2438 async fn test_get_operations_with_schema() {
2439 let ecu_name = "TestECU".to_string();
2440 let mut mock_uds = MockUdsEcu::new();
2441 let mock_file_manager = MockFileManager::new();
2442
2443 mock_uds
2444 .expect_get_ecu_state()
2445 .returning(|_| Ok(mock_ecu_state_online_variant_detected()));
2446 mock_uds
2447 .expect_get_components_operations_info()
2448 .times(1)
2449 .returning(|_, _| Ok(vec![]));
2450
2451 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2452 ecu_name,
2453 mock_uds,
2454 mock_file_manager,
2455 );
2456
2457 let response = get::<MockUdsEcu, MockFileManager>(
2458 UseApi(
2459 Secured(Box::new(TestSecurityPlugin)),
2460 std::marker::PhantomData,
2461 ),
2462 WithRejection(
2463 Query(sovd_interfaces::IncludeSchemaQuery {
2464 include_schema: true,
2465 }),
2466 std::marker::PhantomData,
2467 ),
2468 State(state),
2469 )
2470 .await;
2471
2472 assert_eq!(response.status(), StatusCode::OK);
2473 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2474 .await
2475 .unwrap();
2476 let result: sovd_interfaces::Items<OperationCollectionItem> =
2477 serde_json::from_slice(&body).unwrap();
2478 assert!(
2479 result.schema.is_some(),
2480 "Schema should be included when requested"
2481 );
2482 }
2483 }
2484
2485 mod service_executions {
2486 use std::sync::Arc;
2487
2488 use aide::UseApi;
2489 use axum::{
2490 extract::{Path, Query, State},
2491 http::StatusCode,
2492 };
2493 use axum_extra::extract::WithRejection;
2494 use cda_interfaces::{
2495 DiagComm, DiagCommType, DiagServiceError,
2496 diagservices::{
2497 DiagServiceJsonResponse, DiagServiceResponseType, mock::MockDiagServiceResponse,
2498 },
2499 file_manager::mock::MockFileManager,
2500 mock::MockUdsEcu,
2501 util::std_ext::{lock_read, lock_write},
2502 };
2503 use cda_plugin_communication_management::lifecycle::enabled_communication_access_for_test;
2504 use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
2505 use indexmap::IndexMap;
2506 use sovd_interfaces::{
2507 common::operations::OperationIdItem, components::ecu::operations::ExecutionStatus,
2508 };
2509
2510 use super::super::service::{executions as handlers, executions::id as id_handlers};
2511 use crate::sovd::{
2512 ServiceExecution, locks::insert_test_ecu_lock, tests::create_test_webserver_state,
2513 };
2514
2515 fn make_json_response(data: serde_json::Value) -> MockDiagServiceResponse {
2516 let mut resp = MockDiagServiceResponse::new();
2517 resp.expect_response_type()
2518 .returning(|| DiagServiceResponseType::Positive);
2519 resp.expect_is_empty().returning(|| false);
2520 resp.expect_into_json().return_once(move || {
2521 Ok(DiagServiceJsonResponse {
2522 data,
2523 errors: vec![],
2524 })
2525 });
2526 resp
2527 }
2528
2529 fn make_empty_positive_response() -> MockDiagServiceResponse {
2530 let mut resp = MockDiagServiceResponse::new();
2531 resp.expect_response_type()
2532 .returning(|| DiagServiceResponseType::Positive);
2533 resp.expect_is_empty().returning(|| true);
2534 resp
2535 }
2536
2537 fn make_negative_response() -> MockDiagServiceResponse {
2538 let mut resp = MockDiagServiceResponse::new();
2539 resp.expect_response_type()
2540 .returning(|| DiagServiceResponseType::Negative);
2541 resp.expect_is_empty().returning(|| false);
2542 resp.expect_as_nrc().returning(|| {
2543 Ok(cda_interfaces::diagservices::MappedNRC {
2544 code: Some(0x22),
2545 description: Some("conditionsNotCorrect".to_string()),
2546 sid: None,
2547 })
2548 });
2549 resp
2550 }
2551
2552 async fn ecu_operation_write_handler<
2553 T: cda_interfaces::UdsEcu + cda_interfaces::SchemaProvider + Clone,
2554 >(
2555 req: handlers::WriteHandlerRequest,
2556 ecu_name: &str,
2557 uds: &T,
2558 service_executions: Arc<
2559 std::sync::RwLock<
2560 cda_interfaces::HashMap<
2561 String,
2562 indexmap::IndexMap<uuid::Uuid, ServiceExecution>,
2563 >,
2564 >,
2565 >,
2566 security_plugin: Box<dyn cda_plugin_security::SecurityPlugin>,
2567 opts: handlers::WriteHandlerOptions,
2568 ) -> axum::response::Response {
2569 let ctx = handlers::OperationWriteContext::new(
2570 service_executions,
2571 enabled_communication_access_for_test(),
2572 );
2573 handlers::ecu_operation_write_handler_with_activity::<T>(
2574 req,
2575 ecu_name,
2576 uds,
2577 ctx,
2578 security_plugin,
2579 opts,
2580 )
2581 .await
2582 }
2583
2584 #[tokio::test]
2585 async fn test_list_executions_empty() {
2586 let ecu_name = "TestECU".to_string();
2587 let mock_uds = MockUdsEcu::new();
2588 let mock_file_manager = MockFileManager::new();
2589 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2590 ecu_name,
2591 mock_uds,
2592 mock_file_manager,
2593 );
2594 insert_test_ecu_lock(&state.locks, "TestECU").await;
2595
2596 let response = handlers::get::<MockUdsEcu, MockFileManager>(
2597 UseApi(
2598 Secured(Box::new(TestSecurityPlugin)),
2599 std::marker::PhantomData,
2600 ),
2601 Path(handlers::OperationServicePathParam {
2602 service: "CalibrateSensor".to_string(),
2603 }),
2604 WithRejection(
2605 Query(
2606 sovd_interfaces::components::ecu::operations::service::executions::Query {
2607 include_schema: false,
2608 },
2609 ),
2610 std::marker::PhantomData,
2611 ),
2612 State(state),
2613 )
2614 .await;
2615
2616 assert_eq!(response.status(), StatusCode::OK);
2617 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2618 .await
2619 .unwrap();
2620 let result: sovd_interfaces::Items<OperationIdItem> =
2621 serde_json::from_slice(&body).unwrap();
2622 assert!(result.items.is_empty());
2623 }
2624
2625 #[tokio::test]
2626 async fn test_list_executions_shows_tracked_id() {
2627 let ecu_name = "TestECU".to_string();
2628 let mock_uds = MockUdsEcu::new();
2629 let mock_file_manager = MockFileManager::new();
2630 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2631 ecu_name,
2632 mock_uds,
2633 mock_file_manager,
2634 );
2635 insert_test_ecu_lock(&state.locks, "TestECU").await;
2636
2637 // Pre-populate an execution
2638 let exec_id = uuid::Uuid::new_v4();
2639 lock_write(&state.service_executions)
2640 .entry("CalibrateSensor".to_string())
2641 .or_default()
2642 .insert(
2643 exec_id,
2644 ServiceExecution {
2645 parameters: serde_json::Map::new(),
2646 status: ExecutionStatus::Running,
2647 in_flight: false,
2648 is_created: true,
2649 communication_lease: None,
2650 },
2651 );
2652
2653 let response = handlers::get::<MockUdsEcu, MockFileManager>(
2654 UseApi(
2655 Secured(Box::new(TestSecurityPlugin)),
2656 std::marker::PhantomData,
2657 ),
2658 Path(handlers::OperationServicePathParam {
2659 service: "CalibrateSensor".to_string(),
2660 }),
2661 WithRejection(
2662 Query(
2663 sovd_interfaces::components::ecu::operations::service::executions::Query {
2664 include_schema: false,
2665 },
2666 ),
2667 std::marker::PhantomData,
2668 ),
2669 State(state),
2670 )
2671 .await;
2672
2673 assert_eq!(response.status(), StatusCode::OK);
2674 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2675 .await
2676 .unwrap();
2677 let result: sovd_interfaces::Items<OperationIdItem> =
2678 serde_json::from_slice(&body).unwrap();
2679 assert_eq!(result.items.len(), 1);
2680 assert_eq!(
2681 result.items.first().expect("Expected at least one item").id,
2682 exec_id.to_string()
2683 );
2684 }
2685
2686 #[tokio::test]
2687 async fn test_get_execution_by_id_not_found() {
2688 let ecu_name = "TestECU".to_string();
2689 let mock_uds = MockUdsEcu::new();
2690 let mock_file_manager = MockFileManager::new();
2691 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2692 ecu_name,
2693 mock_uds,
2694 mock_file_manager,
2695 );
2696 insert_test_ecu_lock(&state.locks, "TestECU").await;
2697
2698 let unknown_id = uuid::Uuid::new_v4().to_string();
2699 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2700 UseApi(
2701 Secured(Box::new(TestSecurityPlugin)),
2702 std::marker::PhantomData,
2703 ),
2704 Path(id_handlers::ServiceAndIdPathParam {
2705 service: "CalibrateSensor".to_string(),
2706 id: unknown_id,
2707 }),
2708 WithRejection(
2709 Query(
2710 sovd_interfaces::components::ecu::operations::OperationQuery {
2711 include_schema: false,
2712 suppress_service: false,
2713 },
2714 ),
2715 std::marker::PhantomData,
2716 ),
2717 State(state),
2718 )
2719 .await;
2720
2721 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2722 }
2723
2724 #[tokio::test]
2725 async fn test_get_execution_by_id_calls_request_results() {
2726 let ecu_name = "TestECU".to_string();
2727 let mut mock_uds = MockUdsEcu::new();
2728 let mock_file_manager = MockFileManager::new();
2729
2730 // Expect send with subfunction_id = REQUEST_RESULTS (0x03)
2731 mock_uds
2732 .expect_send()
2733 .withf(|ecu, service, _, payload, map_to_json| {
2734 ecu == "TestECU"
2735 && service.type_ == DiagCommType::Operations
2736 && service.subfunction_id
2737 == Some(cda_interfaces::subfunction_ids::routine::REQUEST_RESULTS)
2738 && service.lookup_name.is_none()
2739 && payload.is_none()
2740 && *map_to_json
2741 })
2742 .times(1)
2743 .returning(|_, _, _, _, _| {
2744 Ok(make_json_response(serde_json::json!({
2745 "result": "ok"
2746 })))
2747 });
2748
2749 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2750 ecu_name,
2751 mock_uds,
2752 mock_file_manager,
2753 );
2754 insert_test_ecu_lock(&state.locks, "TestECU").await;
2755
2756 let exec_id = uuid::Uuid::new_v4();
2757 lock_write(&state.service_executions)
2758 .entry("CalibrateSensor".to_string())
2759 .or_default()
2760 .insert(
2761 exec_id,
2762 ServiceExecution {
2763 parameters: serde_json::Map::new(),
2764 status: ExecutionStatus::Running,
2765 in_flight: false,
2766 is_created: true,
2767 communication_lease: None,
2768 },
2769 );
2770
2771 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2772 UseApi(
2773 Secured(Box::new(TestSecurityPlugin)),
2774 std::marker::PhantomData,
2775 ),
2776 Path(id_handlers::ServiceAndIdPathParam {
2777 service: "CalibrateSensor".to_string(),
2778 id: exec_id.to_string(),
2779 }),
2780 WithRejection(
2781 Query(
2782 sovd_interfaces::components::ecu::operations::OperationQuery {
2783 include_schema: false,
2784 suppress_service: false,
2785 },
2786 ),
2787 std::marker::PhantomData,
2788 ),
2789 State(state),
2790 )
2791 .await;
2792
2793 assert_eq!(response.status(), StatusCode::OK);
2794 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2795 .await
2796 .unwrap();
2797 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2798 assert_eq!(
2799 result.get("status").expect("missing status"),
2800 &serde_json::json!("completed")
2801 );
2802 assert_eq!(
2803 result.get("capability").expect("missing capability"),
2804 &serde_json::json!("execute")
2805 );
2806 assert_eq!(
2807 result
2808 .get("parameters")
2809 .expect("missing parameters")
2810 .get("result")
2811 .expect("missing result"),
2812 &serde_json::json!("ok")
2813 );
2814 }
2815
2816 #[tokio::test]
2817 async fn test_get_execution_suppress_service_skips_send_returns_stored() {
2818 let ecu_name = "TestECU".to_string();
2819 let mut mock_uds = MockUdsEcu::new();
2820 let mock_file_manager = MockFileManager::new();
2821
2822 // suppress_service=true must skip the UDS send entirely
2823 mock_uds.expect_send().times(0);
2824
2825 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2826 ecu_name,
2827 mock_uds,
2828 mock_file_manager,
2829 );
2830 insert_test_ecu_lock(&state.locks, "TestECU").await;
2831
2832 let exec_id = uuid::Uuid::new_v4();
2833 let stored_params = {
2834 let mut m = serde_json::Map::new();
2835 m.insert("stored".to_string(), serde_json::json!("value"));
2836 m
2837 };
2838 lock_write(&state.service_executions)
2839 .entry("CalibrateSensor".to_string())
2840 .or_default()
2841 .insert(
2842 exec_id,
2843 ServiceExecution {
2844 parameters: stored_params,
2845 status: ExecutionStatus::Running,
2846 in_flight: false,
2847 is_created: true,
2848 communication_lease: None,
2849 },
2850 );
2851
2852 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2853 UseApi(
2854 Secured(Box::new(TestSecurityPlugin)),
2855 std::marker::PhantomData,
2856 ),
2857 Path(id_handlers::ServiceAndIdPathParam {
2858 service: "CalibrateSensor".to_string(),
2859 id: exec_id.to_string(),
2860 }),
2861 WithRejection(
2862 Query(
2863 sovd_interfaces::components::ecu::operations::OperationQuery {
2864 include_schema: false,
2865 suppress_service: true,
2866 },
2867 ),
2868 std::marker::PhantomData,
2869 ),
2870 State(state),
2871 )
2872 .await;
2873
2874 // suppress_service=true -> should return 200 with stored params, no UDS send
2875 assert_eq!(response.status(), StatusCode::OK);
2876 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2877 .await
2878 .unwrap();
2879 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2880 assert_eq!(
2881 result.get("status").expect("missing status"),
2882 &serde_json::json!("running")
2883 );
2884 assert_eq!(
2885 result
2886 .get("parameters")
2887 .expect("missing parameters")
2888 .get("stored")
2889 .expect("missing stored"),
2890 &serde_json::json!("value")
2891 );
2892 }
2893
2894 #[tokio::test]
2895 async fn test_get_execution_not_found_without_suppress_returns_error() {
2896 let ecu_name = "TestECU".to_string();
2897 let mut mock_uds = MockUdsEcu::new();
2898 let mock_file_manager = MockFileManager::new();
2899
2900 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
2901 Err(DiagServiceError::NotFound(
2902 "CalibrateSensor_RequestResults not found".to_string(),
2903 ))
2904 });
2905
2906 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2907 ecu_name,
2908 mock_uds,
2909 mock_file_manager,
2910 );
2911 insert_test_ecu_lock(&state.locks, "TestECU").await;
2912
2913 let exec_id = uuid::Uuid::new_v4();
2914 lock_write(&state.service_executions)
2915 .entry("CalibrateSensor".to_string())
2916 .or_default()
2917 .insert(
2918 exec_id,
2919 ServiceExecution {
2920 parameters: serde_json::Map::new(),
2921 status: ExecutionStatus::Running,
2922 in_flight: false,
2923 is_created: true,
2924 communication_lease: None,
2925 },
2926 );
2927
2928 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2929 UseApi(
2930 Secured(Box::new(TestSecurityPlugin)),
2931 std::marker::PhantomData,
2932 ),
2933 Path(id_handlers::ServiceAndIdPathParam {
2934 service: "CalibrateSensor".to_string(),
2935 id: exec_id.to_string(),
2936 }),
2937 WithRejection(
2938 Query(
2939 sovd_interfaces::components::ecu::operations::OperationQuery {
2940 include_schema: false,
2941 suppress_service: false,
2942 },
2943 ),
2944 std::marker::PhantomData,
2945 ),
2946 State(state),
2947 )
2948 .await;
2949
2950 // suppress_service=false -> NotFound from UDS should propagate as error
2951 assert!(response.status().is_client_error() || response.status().is_server_error());
2952 }
2953
2954 #[tokio::test]
2955 async fn test_delete_execution_not_found() {
2956 let ecu_name = "TestECU".to_string();
2957 let mock_uds = MockUdsEcu::new();
2958 let mock_file_manager = MockFileManager::new();
2959 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2960 ecu_name.clone(),
2961 mock_uds,
2962 mock_file_manager,
2963 );
2964 insert_test_ecu_lock(&state.locks, &ecu_name).await;
2965
2966 let unknown_id = uuid::Uuid::new_v4().to_string();
2967 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2968 UseApi(
2969 Secured(Box::new(TestSecurityPlugin)),
2970 std::marker::PhantomData,
2971 ),
2972 Path(id_handlers::ServiceAndIdPathParam {
2973 service: "CalibrateSensor".to_string(),
2974 id: unknown_id,
2975 }),
2976 WithRejection(
2977 Query(
2978 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2979 include_schema: false,
2980 suppress_service: false,
2981 force: false,
2982 },
2983 ),
2984 std::marker::PhantomData,
2985 ),
2986 State(state),
2987 )
2988 .await;
2989
2990 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2991 }
2992
2993 #[tokio::test]
2994 async fn test_delete_execution_calls_stop() {
2995 let ecu_name = "TestECU".to_string();
2996 let mut mock_uds = MockUdsEcu::new();
2997 let mock_file_manager = MockFileManager::new();
2998
2999 // Expect send with subfunction_id = STOP (0x02)
3000 mock_uds
3001 .expect_send()
3002 .withf(|ecu, service, _, _, _| {
3003 ecu == "TestECU"
3004 && service.type_ == DiagCommType::Operations
3005 && service.subfunction_id
3006 == Some(cda_interfaces::subfunction_ids::routine::STOP)
3007 && service.lookup_name.is_none()
3008 })
3009 .times(1)
3010 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3011
3012 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3013 ecu_name,
3014 mock_uds,
3015 mock_file_manager,
3016 );
3017 insert_test_ecu_lock(&state.locks, "TestECU").await;
3018
3019 let exec_id = uuid::Uuid::new_v4();
3020 lock_write(&state.service_executions)
3021 .entry("CalibrateSensor".to_string())
3022 .or_default()
3023 .insert(
3024 exec_id,
3025 ServiceExecution {
3026 parameters: serde_json::Map::new(),
3027 status: ExecutionStatus::Running,
3028 in_flight: false,
3029 is_created: true,
3030 communication_lease: None,
3031 },
3032 );
3033
3034 // Keep a reference to service_executions so we can verify after consuming state
3035 let service_executions_ref = Arc::clone(&state.service_executions);
3036
3037 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3038 UseApi(
3039 Secured(Box::new(TestSecurityPlugin)),
3040 std::marker::PhantomData,
3041 ),
3042 Path(id_handlers::ServiceAndIdPathParam {
3043 service: "CalibrateSensor".to_string(),
3044 id: exec_id.to_string(),
3045 }),
3046 WithRejection(
3047 Query(
3048 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3049 include_schema: false,
3050 suppress_service: false,
3051 force: false,
3052 },
3053 ),
3054 std::marker::PhantomData,
3055 ),
3056 State(state),
3057 )
3058 .await;
3059
3060 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3061 // Verify execution was removed
3062 assert!(
3063 lock_read(&service_executions_ref)
3064 .values()
3065 .all(IndexMap::is_empty)
3066 );
3067 }
3068
3069 #[tokio::test]
3070 async fn test_delete_execution_stop_with_data_returns_200_stopped() {
3071 let ecu_name = "TestECU".to_string();
3072 let mut mock_uds = MockUdsEcu::new();
3073 let mock_file_manager = MockFileManager::new();
3074
3075 // ECU returns a non-empty positive response from Stop
3076 mock_uds
3077 .expect_send()
3078 .withf(|ecu, service, _, _, map_to_json| {
3079 ecu == "TestECU"
3080 && service.subfunction_id
3081 == Some(cda_interfaces::subfunction_ids::routine::STOP)
3082 && *map_to_json
3083 })
3084 .times(1)
3085 .returning(|_, _, _, _, _| {
3086 Ok(make_json_response(serde_json::json!({
3087 "stop_result": "ok"
3088 })))
3089 });
3090
3091 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3092 ecu_name,
3093 mock_uds,
3094 mock_file_manager,
3095 );
3096 insert_test_ecu_lock(&state.locks, "TestECU").await;
3097
3098 let exec_id = uuid::Uuid::new_v4();
3099 lock_write(&state.service_executions)
3100 .entry("CalibrateSensor".to_string())
3101 .or_default()
3102 .insert(
3103 exec_id,
3104 ServiceExecution {
3105 parameters: serde_json::Map::new(),
3106 status: ExecutionStatus::Running,
3107 in_flight: false,
3108 is_created: true,
3109 communication_lease: None,
3110 },
3111 );
3112
3113 let service_executions_ref = Arc::clone(&state.service_executions);
3114
3115 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3116 UseApi(
3117 Secured(Box::new(TestSecurityPlugin)),
3118 std::marker::PhantomData,
3119 ),
3120 Path(id_handlers::ServiceAndIdPathParam {
3121 service: "CalibrateSensor".to_string(),
3122 id: exec_id.to_string(),
3123 }),
3124 WithRejection(
3125 Query(
3126 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3127 include_schema: false,
3128 suppress_service: false,
3129 force: false,
3130 },
3131 ),
3132 std::marker::PhantomData,
3133 ),
3134 State(state),
3135 )
3136 .await;
3137
3138 // ECU returned data -> 200 with status=stopped and the parameters
3139 assert_eq!(response.status(), StatusCode::OK);
3140 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3141 .await
3142 .unwrap();
3143 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3144 assert_eq!(
3145 result.get("status").expect("missing status"),
3146 &serde_json::json!("stopped")
3147 );
3148 assert_eq!(
3149 result
3150 .get("parameters")
3151 .expect("missing parameters")
3152 .get("stop_result")
3153 .expect("missing stop_result"),
3154 &serde_json::json!("ok")
3155 );
3156 // Execution must be removed regardless
3157 assert!(
3158 lock_read(&service_executions_ref)
3159 .values()
3160 .all(IndexMap::is_empty)
3161 );
3162 }
3163
3164 #[tokio::test]
3165 async fn test_delete_execution_stop_with_null_json_returns_200_empty_parameters() {
3166 // Stop maps to JSON Null -> 200 with empty parameters (user-requested extension)
3167 let ecu_name = "TestECU".to_string();
3168 let mut mock_uds = MockUdsEcu::new();
3169 let mock_file_manager = MockFileManager::new();
3170
3171 mock_uds
3172 .expect_send()
3173 .withf(|ecu, service, _, _, map_to_json| {
3174 ecu == "TestECU"
3175 && service.subfunction_id
3176 == Some(cda_interfaces::subfunction_ids::routine::STOP)
3177 && *map_to_json
3178 })
3179 .times(1)
3180 .returning(|_, _, _, _, _| Ok(make_json_response(serde_json::Value::Null)));
3181
3182 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3183 ecu_name,
3184 mock_uds,
3185 mock_file_manager,
3186 );
3187 insert_test_ecu_lock(&state.locks, "TestECU").await;
3188
3189 let exec_id = uuid::Uuid::new_v4();
3190 lock_write(&state.service_executions)
3191 .entry("CalibrateSensor".to_string())
3192 .or_default()
3193 .insert(
3194 exec_id,
3195 ServiceExecution {
3196 parameters: serde_json::Map::new(),
3197 status: ExecutionStatus::Running,
3198 in_flight: false,
3199 is_created: true,
3200 communication_lease: None,
3201 },
3202 );
3203
3204 let service_executions_ref = Arc::clone(&state.service_executions);
3205
3206 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3207 UseApi(
3208 Secured(Box::new(TestSecurityPlugin)),
3209 std::marker::PhantomData,
3210 ),
3211 Path(id_handlers::ServiceAndIdPathParam {
3212 service: "CalibrateSensor".to_string(),
3213 id: exec_id.to_string(),
3214 }),
3215 WithRejection(
3216 Query(
3217 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3218 include_schema: false,
3219 suppress_service: false,
3220 force: false,
3221 },
3222 ),
3223 std::marker::PhantomData,
3224 ),
3225 State(state),
3226 )
3227 .await;
3228
3229 assert_eq!(response.status(), StatusCode::OK);
3230 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3231 .await
3232 .unwrap();
3233 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3234 assert_eq!(
3235 result.get("status").expect("missing status"),
3236 &serde_json::json!("stopped")
3237 );
3238 // parameters: None when empty -> field is omitted from JSON
3239 assert!(
3240 result.get("parameters").is_none(),
3241 "parameters should be absent when Stop returns Null"
3242 );
3243 assert!(
3244 lock_read(&service_executions_ref)
3245 .values()
3246 .all(IndexMap::is_empty)
3247 );
3248 }
3249
3250 #[tokio::test]
3251 async fn test_delete_execution_stop_non_object_json_returns_200_stopped_with_error() {
3252 // Stop maps to a non-object JSON value (e.g. a string) -> 200 stopped, error surfaced
3253 let ecu_name = "TestECU".to_string();
3254 let mut mock_uds = MockUdsEcu::new();
3255 let mock_file_manager = MockFileManager::new();
3256
3257 mock_uds
3258 .expect_send()
3259 .withf(|ecu, service, _, _, map_to_json| {
3260 ecu == "TestECU"
3261 && service.subfunction_id
3262 == Some(cda_interfaces::subfunction_ids::routine::STOP)
3263 && *map_to_json
3264 })
3265 .times(1)
3266 .returning(|_, _, _, _, _| {
3267 Ok(make_json_response(serde_json::json!("unexpected_string")))
3268 });
3269
3270 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3271 ecu_name,
3272 mock_uds,
3273 mock_file_manager,
3274 );
3275 insert_test_ecu_lock(&state.locks, "TestECU").await;
3276
3277 let exec_id = uuid::Uuid::new_v4();
3278 lock_write(&state.service_executions)
3279 .entry("CalibrateSensor".to_string())
3280 .or_default()
3281 .insert(
3282 exec_id,
3283 ServiceExecution {
3284 parameters: serde_json::Map::new(),
3285 status: ExecutionStatus::Running,
3286 in_flight: false,
3287 is_created: true,
3288 communication_lease: None,
3289 },
3290 );
3291
3292 let service_executions_ref = Arc::clone(&state.service_executions);
3293
3294 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3295 UseApi(
3296 Secured(Box::new(TestSecurityPlugin)),
3297 std::marker::PhantomData,
3298 ),
3299 Path(id_handlers::ServiceAndIdPathParam {
3300 service: "CalibrateSensor".to_string(),
3301 id: exec_id.to_string(),
3302 }),
3303 WithRejection(
3304 Query(
3305 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3306 include_schema: false,
3307 suppress_service: false,
3308 force: false,
3309 },
3310 ),
3311 std::marker::PhantomData,
3312 ),
3313 State(state),
3314 )
3315 .await;
3316
3317 assert_eq!(response.status(), StatusCode::OK);
3318 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3319 .await
3320 .unwrap();
3321 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3322 assert_eq!(
3323 result.get("status").expect("missing status"),
3324 &serde_json::json!("stopped")
3325 );
3326 // error list (field name "error" per AsyncGetByIdResponse) must be non-empty
3327 let errors = result.get("error").expect("missing error field");
3328 assert!(errors.is_array() && !errors.as_array().unwrap().is_empty());
3329 assert!(
3330 lock_read(&service_executions_ref)
3331 .values()
3332 .all(IndexMap::is_empty)
3333 );
3334 }
3335
3336 #[tokio::test]
3337 async fn test_delete_execution_stop_into_json_error_returns_200_stopped_with_error() {
3338 // Stop response cannot be parsed (into_json fails) -> 200 stopped, error surfaced
3339 let ecu_name = "TestECU".to_string();
3340 let mut mock_uds = MockUdsEcu::new();
3341 let mock_file_manager = MockFileManager::new();
3342
3343 mock_uds
3344 .expect_send()
3345 .withf(|ecu, service, _, _, map_to_json| {
3346 ecu == "TestECU"
3347 && service.subfunction_id
3348 == Some(cda_interfaces::subfunction_ids::routine::STOP)
3349 && *map_to_json
3350 })
3351 .times(1)
3352 .returning(|_, _, _, _, _| {
3353 let mut resp = MockDiagServiceResponse::new();
3354 resp.expect_response_type()
3355 .returning(|| DiagServiceResponseType::Positive);
3356 resp.expect_is_empty().returning(|| false);
3357 resp.expect_into_json().return_once(|| {
3358 Err(DiagServiceError::BadPayload(
3359 "simulated Stop parse failure".to_string(),
3360 ))
3361 });
3362 Ok(resp)
3363 });
3364
3365 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3366 ecu_name,
3367 mock_uds,
3368 mock_file_manager,
3369 );
3370 insert_test_ecu_lock(&state.locks, "TestECU").await;
3371
3372 let exec_id = uuid::Uuid::new_v4();
3373 lock_write(&state.service_executions)
3374 .entry("CalibrateSensor".to_string())
3375 .or_default()
3376 .insert(
3377 exec_id,
3378 ServiceExecution {
3379 parameters: serde_json::Map::new(),
3380 status: ExecutionStatus::Running,
3381 in_flight: false,
3382 is_created: true,
3383 communication_lease: None,
3384 },
3385 );
3386
3387 let service_executions_ref = Arc::clone(&state.service_executions);
3388
3389 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3390 UseApi(
3391 Secured(Box::new(TestSecurityPlugin)),
3392 std::marker::PhantomData,
3393 ),
3394 Path(id_handlers::ServiceAndIdPathParam {
3395 service: "CalibrateSensor".to_string(),
3396 id: exec_id.to_string(),
3397 }),
3398 WithRejection(
3399 Query(
3400 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3401 include_schema: false,
3402 suppress_service: false,
3403 force: false,
3404 },
3405 ),
3406 std::marker::PhantomData,
3407 ),
3408 State(state),
3409 )
3410 .await;
3411
3412 assert_eq!(response.status(), StatusCode::OK);
3413 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3414 .await
3415 .unwrap();
3416 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3417 assert_eq!(
3418 result.get("status").expect("missing status"),
3419 &serde_json::json!("stopped")
3420 );
3421 let errors = result.get("error").expect("missing error field");
3422 assert!(errors.is_array() && !errors.as_array().unwrap().is_empty());
3423 assert!(
3424 lock_read(&service_executions_ref)
3425 .values()
3426 .all(IndexMap::is_empty)
3427 );
3428 }
3429
3430 #[tokio::test]
3431 async fn test_delete_execution_force_removes_on_uds_error() {
3432 let ecu_name = "TestECU".to_string();
3433 let mut mock_uds = MockUdsEcu::new();
3434 let mock_file_manager = MockFileManager::new();
3435
3436 // UDS returns an error (non-NotFound)
3437 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3438 Err(DiagServiceError::SendFailed("timeout".to_string()))
3439 });
3440
3441 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3442 ecu_name,
3443 mock_uds,
3444 mock_file_manager,
3445 );
3446 insert_test_ecu_lock(&state.locks, "TestECU").await;
3447
3448 let exec_id = uuid::Uuid::new_v4();
3449 lock_write(&state.service_executions)
3450 .entry("CalibrateSensor".to_string())
3451 .or_default()
3452 .insert(
3453 exec_id,
3454 ServiceExecution {
3455 parameters: serde_json::Map::new(),
3456 status: ExecutionStatus::Running,
3457 in_flight: false,
3458 is_created: true,
3459 communication_lease: None,
3460 },
3461 );
3462
3463 // Keep a reference to service_executions so we can verify after consuming state
3464 let service_executions_ref = Arc::clone(&state.service_executions);
3465
3466 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3467 UseApi(
3468 Secured(Box::new(TestSecurityPlugin)),
3469 std::marker::PhantomData,
3470 ),
3471 Path(id_handlers::ServiceAndIdPathParam {
3472 service: "CalibrateSensor".to_string(),
3473 id: exec_id.to_string(),
3474 }),
3475 WithRejection(
3476 Query(
3477 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3478 include_schema: false,
3479 suppress_service: false,
3480 force: true,
3481 },
3482 ),
3483 std::marker::PhantomData,
3484 ),
3485 State(state),
3486 )
3487 .await;
3488
3489 // force=true -> removes execution even on error
3490 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3491 assert!(
3492 lock_read(&service_executions_ref)
3493 .values()
3494 .all(IndexMap::is_empty)
3495 );
3496 }
3497
3498 #[tokio::test]
3499 async fn test_delete_execution_force_removes_on_negative_response() {
3500 let ecu_name = "TestECU".to_string();
3501 let mut mock_uds = MockUdsEcu::new();
3502 let mock_file_manager = MockFileManager::new();
3503
3504 mock_uds
3505 .expect_send()
3506 .times(1)
3507 .returning(|_, _, _, _, _| Ok(make_negative_response()));
3508
3509 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3510 ecu_name,
3511 mock_uds,
3512 mock_file_manager,
3513 );
3514 insert_test_ecu_lock(&state.locks, "TestECU").await;
3515
3516 let exec_id = uuid::Uuid::new_v4();
3517 lock_write(&state.service_executions)
3518 .entry("CalibrateSensor".to_string())
3519 .or_default()
3520 .insert(
3521 exec_id,
3522 ServiceExecution {
3523 parameters: serde_json::Map::new(),
3524 status: ExecutionStatus::Running,
3525 in_flight: false,
3526 is_created: true,
3527 communication_lease: None,
3528 },
3529 );
3530
3531 let service_executions_ref = Arc::clone(&state.service_executions);
3532
3533 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3534 UseApi(
3535 Secured(Box::new(TestSecurityPlugin)),
3536 std::marker::PhantomData,
3537 ),
3538 Path(id_handlers::ServiceAndIdPathParam {
3539 service: "CalibrateSensor".to_string(),
3540 id: exec_id.to_string(),
3541 }),
3542 WithRejection(
3543 Query(
3544 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3545 include_schema: false,
3546 suppress_service: false,
3547 force: true,
3548 },
3549 ),
3550 std::marker::PhantomData,
3551 ),
3552 State(state),
3553 )
3554 .await;
3555
3556 // force=true -> removes execution even on negative ECU response
3557 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3558 assert!(
3559 lock_read(&service_executions_ref)
3560 .values()
3561 .all(IndexMap::is_empty)
3562 );
3563 }
3564
3565 #[tokio::test]
3566 async fn test_delete_execution_without_force_returns_error_on_uds_failure() {
3567 let ecu_name = "TestECU".to_string();
3568 let mut mock_uds = MockUdsEcu::new();
3569 let mock_file_manager = MockFileManager::new();
3570
3571 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3572 Err(DiagServiceError::SendFailed("timeout".to_string()))
3573 });
3574
3575 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3576 ecu_name,
3577 mock_uds,
3578 mock_file_manager,
3579 );
3580 insert_test_ecu_lock(&state.locks, "TestECU").await;
3581
3582 let exec_id = uuid::Uuid::new_v4();
3583 lock_write(&state.service_executions)
3584 .entry("CalibrateSensor".to_string())
3585 .or_default()
3586 .insert(
3587 exec_id,
3588 ServiceExecution {
3589 parameters: serde_json::Map::new(),
3590 status: ExecutionStatus::Running,
3591 in_flight: false,
3592 is_created: true,
3593 communication_lease: None,
3594 },
3595 );
3596
3597 // Keep a reference to service_executions so we can verify after consuming state
3598 let service_executions_ref = Arc::clone(&state.service_executions);
3599
3600 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3601 UseApi(
3602 Secured(Box::new(TestSecurityPlugin)),
3603 std::marker::PhantomData,
3604 ),
3605 Path(id_handlers::ServiceAndIdPathParam {
3606 service: "CalibrateSensor".to_string(),
3607 id: exec_id.to_string(),
3608 }),
3609 WithRejection(
3610 Query(
3611 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3612 include_schema: false,
3613 suppress_service: false,
3614 force: false,
3615 },
3616 ),
3617 std::marker::PhantomData,
3618 ),
3619 State(state),
3620 )
3621 .await;
3622
3623 // force=false -> error should be returned, execution should remain
3624 assert!(response.status().is_client_error() || response.status().is_server_error());
3625 assert_eq!(lock_read(&service_executions_ref).len(), 1);
3626 }
3627
3628 #[tokio::test]
3629 async fn test_delete_execution_negative_response_resets_in_flight() {
3630 let ecu_name = "TestECU".to_string();
3631 let mut mock_uds = MockUdsEcu::new();
3632 let mock_file_manager = MockFileManager::new();
3633
3634 mock_uds
3635 .expect_send()
3636 .times(1)
3637 .returning(|_, _, _, _, _| Ok(make_negative_response()));
3638
3639 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3640 ecu_name,
3641 mock_uds,
3642 mock_file_manager,
3643 );
3644 insert_test_ecu_lock(&state.locks, "TestECU").await;
3645
3646 let exec_id = uuid::Uuid::new_v4();
3647 lock_write(&state.service_executions)
3648 .entry("CalibrateSensor".to_string())
3649 .or_default()
3650 .insert(
3651 exec_id,
3652 ServiceExecution {
3653 parameters: serde_json::Map::new(),
3654 status: ExecutionStatus::Running,
3655 in_flight: false,
3656 is_created: true,
3657 communication_lease: None,
3658 },
3659 );
3660
3661 let service_executions_ref = Arc::clone(&state.service_executions);
3662
3663 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3664 UseApi(
3665 Secured(Box::new(TestSecurityPlugin)),
3666 std::marker::PhantomData,
3667 ),
3668 Path(id_handlers::ServiceAndIdPathParam {
3669 service: "CalibrateSensor".to_string(),
3670 id: exec_id.to_string(),
3671 }),
3672 WithRejection(
3673 Query(
3674 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3675 include_schema: false,
3676 suppress_service: false,
3677 force: false,
3678 },
3679 ),
3680 std::marker::PhantomData,
3681 ),
3682 State(state),
3683 )
3684 .await;
3685
3686 // Negative ECU response without force -> error returned, in_flight reset
3687 assert!(response.status().is_client_error() || response.status().is_server_error());
3688 let guard = lock_read(&service_executions_ref);
3689 let exec = guard
3690 .get("CalibrateSensor")
3691 .and_then(|m| m.get(&exec_id))
3692 .expect("execution should still exist");
3693 assert!(!exec.in_flight, "in_flight should be reset to false");
3694 }
3695
3696 #[tokio::test]
3697 async fn test_delete_execution_suppress_service_removes_on_not_found() {
3698 let ecu_name = "TestECU".to_string();
3699 let mut mock_uds = MockUdsEcu::new();
3700 let mock_file_manager = MockFileManager::new();
3701
3702 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3703 Err(DiagServiceError::NotFound(
3704 "CalibrateSensor_Stop not found".to_string(),
3705 ))
3706 });
3707
3708 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3709 ecu_name,
3710 mock_uds,
3711 mock_file_manager,
3712 );
3713 insert_test_ecu_lock(&state.locks, "TestECU").await;
3714
3715 let exec_id = uuid::Uuid::new_v4();
3716 lock_write(&state.service_executions)
3717 .entry("CalibrateSensor".to_string())
3718 .or_default()
3719 .insert(
3720 exec_id,
3721 ServiceExecution {
3722 parameters: serde_json::Map::new(),
3723 status: ExecutionStatus::Running,
3724 in_flight: false,
3725 is_created: true,
3726 communication_lease: None,
3727 },
3728 );
3729
3730 // Keep a reference to service_executions so we can verify after consuming state
3731 let service_executions_ref = Arc::clone(&state.service_executions);
3732
3733 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3734 UseApi(
3735 Secured(Box::new(TestSecurityPlugin)),
3736 std::marker::PhantomData,
3737 ),
3738 Path(id_handlers::ServiceAndIdPathParam {
3739 service: "CalibrateSensor".to_string(),
3740 id: exec_id.to_string(),
3741 }),
3742 WithRejection(
3743 Query(
3744 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3745 include_schema: false,
3746 suppress_service: true,
3747 force: false,
3748 },
3749 ),
3750 std::marker::PhantomData,
3751 ),
3752 State(state),
3753 )
3754 .await;
3755
3756 // suppress_service=true on NotFound -> removes execution, returns 204
3757 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3758 assert!(
3759 lock_read(&service_executions_ref)
3760 .values()
3761 .all(IndexMap::is_empty)
3762 );
3763 }
3764
3765 #[tokio::test]
3766 async fn test_get_execution_in_flight_returns_conflict() {
3767 let ecu_name = "TestECU".to_string();
3768 let mock_uds = MockUdsEcu::new();
3769 let mock_file_manager = MockFileManager::new();
3770 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3771 ecu_name,
3772 mock_uds,
3773 mock_file_manager,
3774 );
3775 insert_test_ecu_lock(&state.locks, "TestECU").await;
3776
3777 let exec_id = uuid::Uuid::new_v4();
3778 lock_write(&state.service_executions)
3779 .entry("CalibrateSensor".to_string())
3780 .or_default()
3781 .insert(
3782 exec_id,
3783 ServiceExecution {
3784 parameters: serde_json::Map::new(),
3785 status: ExecutionStatus::Running,
3786 in_flight: true,
3787 is_created: true,
3788 communication_lease: None,
3789 },
3790 );
3791
3792 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
3793 UseApi(
3794 Secured(Box::new(TestSecurityPlugin)),
3795 std::marker::PhantomData,
3796 ),
3797 Path(id_handlers::ServiceAndIdPathParam {
3798 service: "CalibrateSensor".to_string(),
3799 id: exec_id.to_string(),
3800 }),
3801 WithRejection(
3802 Query(
3803 sovd_interfaces::components::ecu::operations::OperationQuery {
3804 include_schema: false,
3805 suppress_service: false,
3806 },
3807 ),
3808 std::marker::PhantomData,
3809 ),
3810 State(state),
3811 )
3812 .await;
3813
3814 assert_eq!(response.status(), StatusCode::CONFLICT);
3815 }
3816
3817 #[tokio::test]
3818 async fn test_delete_execution_in_flight_returns_conflict() {
3819 let ecu_name = "TestECU".to_string();
3820 let mock_uds = MockUdsEcu::new();
3821 let mock_file_manager = MockFileManager::new();
3822 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3823 ecu_name.clone(),
3824 mock_uds,
3825 mock_file_manager,
3826 );
3827 insert_test_ecu_lock(&state.locks, &ecu_name).await;
3828
3829 let exec_id = uuid::Uuid::new_v4();
3830 lock_write(&state.service_executions)
3831 .entry("CalibrateSensor".to_string())
3832 .or_default()
3833 .insert(
3834 exec_id,
3835 ServiceExecution {
3836 parameters: serde_json::Map::new(),
3837 status: ExecutionStatus::Running,
3838 in_flight: true,
3839 is_created: true,
3840 communication_lease: None,
3841 },
3842 );
3843
3844 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3845 UseApi(
3846 Secured(Box::new(TestSecurityPlugin)),
3847 std::marker::PhantomData,
3848 ),
3849 Path(id_handlers::ServiceAndIdPathParam {
3850 service: "CalibrateSensor".to_string(),
3851 id: exec_id.to_string(),
3852 }),
3853 WithRejection(
3854 Query(
3855 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3856 include_schema: false,
3857 suppress_service: false,
3858 force: false,
3859 },
3860 ),
3861 std::marker::PhantomData,
3862 ),
3863 State(state),
3864 )
3865 .await;
3866
3867 assert_eq!(response.status(), StatusCode::CONFLICT);
3868 }
3869
3870 fn make_post_headers() -> axum::http::HeaderMap {
3871 let mut headers = axum::http::HeaderMap::new();
3872 headers.insert(
3873 axum::http::header::CONTENT_TYPE,
3874 axum::http::HeaderValue::from_static("application/json"),
3875 );
3876 headers.insert(
3877 axum::http::header::ACCEPT,
3878 axum::http::HeaderValue::from_static("application/json"),
3879 );
3880 headers
3881 }
3882
3883 #[tokio::test]
3884 async fn test_post_operation_conflict_when_running_execution_exists() {
3885 let ecu_name = "TestECU".to_string();
3886 let mock_uds = MockUdsEcu::new();
3887 let mock_file_manager = MockFileManager::new();
3888
3889 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3890 ecu_name.clone(),
3891 mock_uds,
3892 mock_file_manager,
3893 );
3894
3895 // Pre-populate a running execution for CalibrateSensor
3896 lock_write(&state.service_executions)
3897 .entry("CalibrateSensor".to_string())
3898 .or_default()
3899 .insert(
3900 uuid::Uuid::new_v4(),
3901 ServiceExecution {
3902 parameters: serde_json::Map::new(),
3903 status: ExecutionStatus::Running,
3904 in_flight: false,
3905 is_created: true,
3906 communication_lease: None,
3907 },
3908 );
3909
3910 let response = ecu_operation_write_handler::<MockUdsEcu>(
3911 handlers::WriteHandlerRequest {
3912 service: "CalibrateSensor".to_string(),
3913 headers: make_post_headers(),
3914 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3915 },
3916 &ecu_name,
3917 &state.uds,
3918 Arc::clone(&state.service_executions),
3919 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3920 handlers::WriteHandlerOptions {
3921 include_schema: false,
3922 suppress_service: false,
3923 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3924 },
3925 )
3926 .await;
3927
3928 assert_eq!(response.status(), StatusCode::CONFLICT);
3929 }
3930
3931 #[tokio::test]
3932 async fn test_post_operation_no_conflict_for_different_service() {
3933 // An execution running for ServiceA must NOT block ServiceB
3934 let ecu_name = "TestECU".to_string();
3935 let mut mock_uds = MockUdsEcu::new();
3936 let mock_file_manager = MockFileManager::new();
3937
3938 mock_uds
3939 .expect_get_routine_subfunctions()
3940 .times(1)
3941 .returning(|_, _, _| {
3942 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3943 has_stop: false,
3944 has_request_results: false,
3945 })
3946 });
3947 mock_uds
3948 .expect_send()
3949 .times(1)
3950 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3951
3952 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3953 ecu_name.clone(),
3954 mock_uds,
3955 mock_file_manager,
3956 );
3957
3958 // Pre-populate a running execution for a DIFFERENT service
3959 lock_write(&state.service_executions)
3960 .entry("OtherService".to_string())
3961 .or_default()
3962 .insert(
3963 uuid::Uuid::new_v4(),
3964 ServiceExecution {
3965 parameters: serde_json::Map::new(),
3966 status: ExecutionStatus::Running,
3967 in_flight: false,
3968 is_created: true,
3969 communication_lease: None,
3970 },
3971 );
3972
3973 let response = ecu_operation_write_handler::<MockUdsEcu>(
3974 handlers::WriteHandlerRequest {
3975 service: "CalibrateSensor".to_string(),
3976 headers: make_post_headers(),
3977 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3978 },
3979 &ecu_name,
3980 &state.uds,
3981 Arc::clone(&state.service_executions),
3982 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3983 handlers::WriteHandlerOptions {
3984 include_schema: false,
3985 suppress_service: false,
3986 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3987 },
3988 )
3989 .await;
3990
3991 // Different service -> no conflict, should pass through to 200
3992 assert_eq!(response.status(), StatusCode::OK);
3993 }
3994
3995 #[tokio::test]
3996 async fn test_post_operation_service_not_found_returns_404() {
3997 let ecu_name = "TestECU".to_string();
3998 let mut mock_uds = MockUdsEcu::new();
3999 let mock_file_manager = MockFileManager::new();
4000
4001 mock_uds
4002 .expect_get_routine_subfunctions()
4003 .withf(|ecu, svc, _p| ecu == "TestECU" && svc == "CalibrateSensor")
4004 .times(1)
4005 .returning(|_, _, _| {
4006 Err(DiagServiceError::NotFound(
4007 "Routine 'CalibrateSensor' not found in ECU description".to_string(),
4008 ))
4009 });
4010 mock_uds
4011 .expect_get_io_control_service()
4012 .withf(|ecu, svc, _p| ecu == "TestECU" && svc == "CalibrateSensor")
4013 .times(1)
4014 .returning(|_, _, _| {
4015 Err(DiagServiceError::NotFound(
4016 "No InputOutputControlByIdentifier service found".to_owned(),
4017 ))
4018 });
4019
4020 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4021 ecu_name.clone(),
4022 mock_uds,
4023 mock_file_manager,
4024 );
4025
4026 let response = ecu_operation_write_handler::<MockUdsEcu>(
4027 handlers::WriteHandlerRequest {
4028 service: "CalibrateSensor".to_string(),
4029 headers: make_post_headers(),
4030 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
4031 },
4032 &ecu_name,
4033 &state.uds,
4034 Arc::clone(&state.service_executions),
4035 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
4036 handlers::WriteHandlerOptions {
4037 include_schema: false,
4038 suppress_service: false,
4039 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
4040 },
4041 )
4042 .await;
4043
4044 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4045 }
4046
4047 #[tokio::test]
4048 async fn test_post_operation_sync_returns_200_on_empty_response() {
4049 let ecu_name = "TestECU".to_string();
4050 let mut mock_uds = MockUdsEcu::new();
4051 let mock_file_manager = MockFileManager::new();
4052
4053 mock_uds
4054 .expect_get_routine_subfunctions()
4055 .times(1)
4056 .returning(|_, _, _| {
4057 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
4058 has_stop: false,
4059 has_request_results: false,
4060 })
4061 });
4062 mock_uds
4063 .expect_send()
4064 .times(1)
4065 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
4066
4067 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4068 ecu_name.clone(),
4069 mock_uds,
4070 mock_file_manager,
4071 );
4072
4073 let response = ecu_operation_write_handler::<MockUdsEcu>(
4074 handlers::WriteHandlerRequest {
4075 service: "CalibrateSensor".to_string(),
4076 headers: make_post_headers(),
4077 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
4078 },
4079 &ecu_name,
4080 &state.uds,
4081 Arc::clone(&state.service_executions),
4082 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
4083 handlers::WriteHandlerOptions {
4084 include_schema: false,
4085 suppress_service: false,
4086 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
4087 },
4088 )
4089 .await;
4090
4091 assert_eq!(response.status(), StatusCode::OK);
4092 }
4093
4094 #[tokio::test]
4095 async fn test_post_named_io_control_uses_exact_operation_service() {
4096 let ecu_name = "TestECU".to_string();
4097 let mut mock_uds = MockUdsEcu::new();
4098 let mock_file_manager = MockFileManager::new();
4099
4100 mock_uds
4101 .expect_get_routine_subfunctions()
4102 .times(1)
4103 .returning(|_, _, _| {
4104 Err(DiagServiceError::NotFound(
4105 "Not a RoutineControl service".to_owned(),
4106 ))
4107 });
4108 mock_uds
4109 .expect_get_io_control_service()
4110 .withf(|ecu, svc, _p| ecu == "TestECU" && svc == "TestOutput_Control")
4111 .times(1)
4112 .returning(|_, _, _| {
4113 Ok(DiagComm {
4114 name: "TestOutput_Control".to_owned(),
4115 type_: DiagCommType::Operations,
4116 lookup_name: Some("TestOutput_Control".to_owned()),
4117 subfunction_id: None,
4118 })
4119 });
4120 mock_uds
4121 .expect_send()
4122 .withf(|ecu, service, _, payload, map_to_json| {
4123 ecu == "TestECU"
4124 && service.type_ == DiagCommType::Operations
4125 && service.name == "TestOutput_Control"
4126 && service.lookup_name.as_deref() == Some("TestOutput_Control")
4127 && service.subfunction_id.is_none()
4128 && payload.is_some()
4129 && *map_to_json
4130 })
4131 .times(1)
4132 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
4133
4134 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4135 ecu_name.clone(),
4136 mock_uds,
4137 mock_file_manager,
4138 );
4139
4140 let response = ecu_operation_write_handler::<MockUdsEcu>(
4141 handlers::WriteHandlerRequest {
4142 service: "TestOutput_Control".to_owned(),
4143 headers: make_post_headers(),
4144 body: axum::body::Bytes::from_static(
4145 br#"{"parameters":{"OutputValue":"ACTIVE"}}"#,
4146 ),
4147 },
4148 &ecu_name,
4149 &state.uds,
4150 Arc::clone(&state.service_executions),
4151 Box::new(TestSecurityPlugin),
4152 handlers::WriteHandlerOptions {
4153 include_schema: false,
4154 suppress_service: false,
4155 base_path: "http://localhost/operations/TestOutput_Control/executions"
4156 .to_owned(),
4157 },
4158 )
4159 .await;
4160
4161 assert_eq!(response.status(), StatusCode::OK);
4162 }
4163
4164 #[tokio::test]
4165 async fn test_post_operation_async_returns_202_and_tracks_execution() {
4166 let ecu_name = "TestECU".to_string();
4167 let mut mock_uds = MockUdsEcu::new();
4168 let mock_file_manager = MockFileManager::new();
4169
4170 mock_uds
4171 .expect_get_routine_subfunctions()
4172 .times(1)
4173 .returning(|_, _, _| {
4174 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
4175 has_stop: true,
4176 has_request_results: true,
4177 })
4178 });
4179 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
4180 Ok(make_json_response(serde_json::json!({
4181 "routineInfo": 1,
4182 "result": 0
4183 })))
4184 });
4185
4186 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4187 ecu_name.clone(),
4188 mock_uds,
4189 mock_file_manager,
4190 );
4191
4192 let service_executions_ref = Arc::clone(&state.service_executions);
4193
4194 let response = ecu_operation_write_handler::<MockUdsEcu>(
4195 handlers::WriteHandlerRequest {
4196 service: "CalibrateSensor".to_string(),
4197 headers: make_post_headers(),
4198 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
4199 },
4200 &ecu_name,
4201 &state.uds,
4202 Arc::clone(&state.service_executions),
4203 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
4204 handlers::WriteHandlerOptions {
4205 include_schema: false,
4206 suppress_service: false,
4207 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
4208 },
4209 )
4210 .await;
4211
4212 assert_eq!(response.status(), StatusCode::ACCEPTED);
4213 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4214 .await
4215 .unwrap();
4216 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
4217 assert!(result.get("id").is_some(), "202 body must have id");
4218 assert_eq!(result.get("status"), Some(&serde_json::json!("running")));
4219 assert_eq!(
4220 result.get("parameters"),
4221 Some(&serde_json::json!({"routineInfo": 1, "result": 0}))
4222 );
4223 assert_eq!(lock_read(&service_executions_ref).len(), 1);
4224 }
4225
4226 #[tokio::test]
4227 async fn test_post_operation_suppress_service_async_skips_send_returns_202_and_tracks() {
4228 let ecu_name = "TestECU".to_string();
4229 let mut mock_uds = MockUdsEcu::new();
4230 let mock_file_manager = MockFileManager::new();
4231
4232 mock_uds.expect_get_routine_subfunctions().times(0);
4233 // send must NOT be called when suppress_service=true
4234 mock_uds.expect_send().times(0);
4235
4236 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4237 ecu_name.clone(),
4238 mock_uds,
4239 mock_file_manager,
4240 );
4241
4242 let service_executions_ref = Arc::clone(&state.service_executions);
4243
4244 let response = ecu_operation_write_handler::<MockUdsEcu>(
4245 handlers::WriteHandlerRequest {
4246 service: "CalibrateSensor".to_string(),
4247 headers: make_post_headers(),
4248 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
4249 },
4250 &ecu_name,
4251 &state.uds,
4252 Arc::clone(&state.service_executions),
4253 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
4254 handlers::WriteHandlerOptions {
4255 include_schema: false,
4256 suppress_service: true,
4257 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
4258 },
4259 )
4260 .await;
4261
4262 assert_eq!(response.status(), StatusCode::ACCEPTED);
4263 // execution still tracked even though UDS was not called
4264 assert_eq!(lock_read(&service_executions_ref).len(), 1);
4265 }
4266
4267 #[tokio::test]
4268 async fn test_post_operation_async_into_json_error_returns_empty_parameters() {
4269 let ecu_name = "TestECU".to_string();
4270 let mut mock_uds = MockUdsEcu::new();
4271 let mock_file_manager = MockFileManager::new();
4272
4273 mock_uds
4274 .expect_get_routine_subfunctions()
4275 .times(1)
4276 .returning(|_, _, _| {
4277 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
4278 has_stop: true,
4279 has_request_results: true,
4280 })
4281 });
4282 // send returns a response whose into_json() fails
4283 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
4284 let mut resp = MockDiagServiceResponse::new();
4285 resp.expect_response_type()
4286 .returning(|| DiagServiceResponseType::Positive);
4287 resp.expect_is_empty().returning(|| false);
4288 resp.expect_into_json().return_once(|| {
4289 Err(DiagServiceError::BadPayload(
4290 "simulated parse failure".to_string(),
4291 ))
4292 });
4293 Ok(resp)
4294 });
4295
4296 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4297 ecu_name.clone(),
4298 mock_uds,
4299 mock_file_manager,
4300 );
4301
4302 let service_executions_ref = Arc::clone(&state.service_executions);
4303
4304 let response = ecu_operation_write_handler::<MockUdsEcu>(
4305 handlers::WriteHandlerRequest {
4306 service: "CalibrateSensor".to_string(),
4307 headers: make_post_headers(),
4308 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
4309 },
4310 &ecu_name,
4311 &state.uds,
4312 Arc::clone(&state.service_executions),
4313 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
4314 handlers::WriteHandlerOptions {
4315 include_schema: false,
4316 suppress_service: false,
4317 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
4318 },
4319 )
4320 .await;
4321
4322 // Decoding failures do not prevent the asynchronous execution from starting.
4323 assert_eq!(response.status(), StatusCode::ACCEPTED);
4324 // Execution must still be tracked
4325 assert_eq!(lock_read(&service_executions_ref).len(), 1);
4326 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4327 .await
4328 .unwrap();
4329 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
4330 assert!(result.get("id").is_some(), "202 body must have id");
4331 assert!(result.get("status").is_some(), "202 body must have status");
4332 assert_eq!(result.get("parameters"), Some(&serde_json::json!({})));
4333 let errors = result
4334 .get("errors")
4335 .and_then(serde_json::Value::as_array)
4336 .expect("202 body must contain errors");
4337 assert_eq!(errors.len(), 1);
4338 assert_eq!(
4339 errors.first().and_then(|error| error.get("path")),
4340 Some(&serde_json::json!("/parameters"))
4341 );
4342 assert_eq!(
4343 errors
4344 .first()
4345 .and_then(|error| error.get("error"))
4346 .and_then(|error| error.get("error_code")),
4347 Some(&serde_json::json!("invalid-response-content"))
4348 );
4349 }
4350
4351 #[tokio::test]
4352 async fn test_post_operation_async_non_object_json_returns_empty_parameters() {
4353 let ecu_name = "TestECU".to_string();
4354 let mut mock_uds = MockUdsEcu::new();
4355 let mock_file_manager = MockFileManager::new();
4356
4357 mock_uds
4358 .expect_get_routine_subfunctions()
4359 .times(1)
4360 .returning(|_, _, _| {
4361 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
4362 has_stop: true,
4363 has_request_results: true,
4364 })
4365 });
4366 // send returns a response whose into_json() gives a non-Object JSON value
4367 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
4368 let mut resp = MockDiagServiceResponse::new();
4369 resp.expect_response_type()
4370 .returning(|| DiagServiceResponseType::Positive);
4371 resp.expect_is_empty().returning(|| false);
4372 resp.expect_into_json().return_once(|| {
4373 Ok(DiagServiceJsonResponse {
4374 data: serde_json::Value::String("unexpected_string".to_string()),
4375 errors: vec![],
4376 })
4377 });
4378 Ok(resp)
4379 });
4380
4381 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4382 ecu_name.clone(),
4383 mock_uds,
4384 mock_file_manager,
4385 );
4386
4387 let service_executions_ref = Arc::clone(&state.service_executions);
4388
4389 let response = ecu_operation_write_handler::<MockUdsEcu>(
4390 handlers::WriteHandlerRequest {
4391 service: "CalibrateSensor".to_string(),
4392 headers: make_post_headers(),
4393 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
4394 },
4395 &ecu_name,
4396 &state.uds,
4397 Arc::clone(&state.service_executions),
4398 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
4399 handlers::WriteHandlerOptions {
4400 include_schema: false,
4401 suppress_service: false,
4402 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
4403 },
4404 )
4405 .await;
4406
4407 // Non-object response data does not prevent the asynchronous execution from starting.
4408 assert_eq!(response.status(), StatusCode::ACCEPTED);
4409 assert_eq!(lock_read(&service_executions_ref).len(), 1);
4410 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4411 .await
4412 .unwrap();
4413 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
4414 assert!(result.get("id").is_some(), "202 body must have id");
4415 assert!(result.get("status").is_some(), "202 body must have status");
4416 assert_eq!(result.get("parameters"), Some(&serde_json::json!({})));
4417 assert_eq!(
4418 result
4419 .get("errors")
4420 .and_then(serde_json::Value::as_array)
4421 .map(Vec::len),
4422 Some(1)
4423 );
4424 }
4425
4426 #[tokio::test]
4427 async fn test_request_results_into_json_error_surfaces_in_errors_field() {
4428 let ecu_name = "TestECU".to_string();
4429 let mut mock_uds = MockUdsEcu::new();
4430 let mock_file_manager = MockFileManager::new();
4431
4432 // RequestResults returns a non-empty response whose into_json() fails
4433 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
4434 let mut resp = MockDiagServiceResponse::new();
4435 resp.expect_response_type()
4436 .returning(|| DiagServiceResponseType::Positive);
4437 resp.expect_is_empty().returning(|| false);
4438 resp.expect_into_json().return_once(|| {
4439 Err(DiagServiceError::BadPayload(
4440 "simulated parse failure".to_string(),
4441 ))
4442 });
4443 Ok(resp)
4444 });
4445
4446 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
4447 ecu_name,
4448 mock_uds,
4449 mock_file_manager,
4450 );
4451 insert_test_ecu_lock(&state.locks, "TestECU").await;
4452
4453 let exec_id = uuid::Uuid::new_v4();
4454 lock_write(&state.service_executions)
4455 .entry("CalibrateSensor".to_string())
4456 .or_default()
4457 .insert(
4458 exec_id,
4459 ServiceExecution {
4460 parameters: serde_json::Map::new(),
4461 status: ExecutionStatus::Running,
4462 in_flight: false,
4463 is_created: true,
4464 communication_lease: None,
4465 },
4466 );
4467
4468 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
4469 UseApi(
4470 Secured(Box::new(TestSecurityPlugin)),
4471 std::marker::PhantomData,
4472 ),
4473 Path(id_handlers::ServiceAndIdPathParam {
4474 service: "CalibrateSensor".to_string(),
4475 id: exec_id.to_string(),
4476 }),
4477 WithRejection(
4478 Query(
4479 sovd_interfaces::components::ecu::operations::OperationQuery {
4480 include_schema: false,
4481 suppress_service: false,
4482 },
4483 ),
4484 std::marker::PhantomData,
4485 ),
4486 State(state),
4487 )
4488 .await;
4489
4490 // Must be 200, not 500
4491 assert_eq!(response.status(), StatusCode::OK);
4492 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4493 .await
4494 .unwrap();
4495 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
4496 // Spec Table 189: field is named `error` (singular key, array value)
4497 let errors = result.get("error").expect("missing error field");
4498 assert!(
4499 errors.as_array().is_some_and(|a| !a.is_empty()),
4500 "error should be non-empty when RequestResults into_json fails"
4501 );
4502 }
4503 }
4504}