1/*
2 * SPDX-FileCopyrightText: 2026 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 std::{
15 panic::AssertUnwindSafe,
16 time::{Duration, Instant},
17};
18
19use cda_interfaces::communication_control::{
20 CommunicationInitMode, CommunicationSettings, PostUpdateCommunicationMode, VariantDetectionMode,
21};
22use futures::FutureExt;
23use http::HeaderMap;
24use sovd_interfaces::{
25 apps::sovd2uds::operations::runtimefilesupdate::ExecutionMode,
26 error::{ApiErrorResponse, ErrorCode},
27};
28
29use crate::{
30 sovd::{ECU_FLXC1000_ENDPOINT, runtimefiles},
31 util::{
32 ecusim,
33 http::{auth_header, response_to_t, send_cda_request},
34 runtime::{
35 restart_cda, restart_cda_with_config, setup_integration_test,
36 setup_integration_test_without_cda, skip_for_can, use_can, wait_for_ecus_online,
37 with_temporary_cda,
38 },
39 },
40};
41
42const ECU_SIM_NAME: &str = "flxc1000";
43
44fn flxc1000_data_path() -> String {
45 format!("/vehicle/v15/{ECU_FLXC1000_ENDPOINT}/data")
46}
47
48enum CdaMode {
49 Deferred(PostUpdateCommunicationMode),
50 Disabled,
51}
52
53impl CdaMode {
54 fn config(
55 self,
56 runtime: &crate::util::runtime::TestRuntime,
57 ) -> opensovd_cda_lib::config::configfile::Configuration {
58 let (init_mode, post_update_mode) = match self {
59 Self::Deferred(post_update_mode) => (CommunicationInitMode::OnDemand, post_update_mode),
60 Self::Disabled => (
61 CommunicationInitMode::Disabled,
62 PostUpdateCommunicationMode::Enabled,
63 ),
64 };
65
66 let mut config = runtime.config.clone();
67 config.communication = CommunicationSettings {
68 init_mode,
69 variant_detection: VariantDetectionMode::Always,
70 post_update_mode,
71 deferred_retry_after_seconds: 1,
72 };
73 config
74 }
75}
76
77/// Polls `path` with the given (already-authenticated) headers until it stops
78/// returning 503, or the timeout elapses.
79async fn wait_until_not_pending(
80 base_url: &str,
81 path: &str,
82 headers: &HeaderMap,
83 timeout: Duration,
84) -> reqwest::Response {
85 let client = reqwest::Client::new();
86 let deadline = Instant::now()
87 .checked_add(timeout)
88 .expect("Timeout is too large");
89 loop {
90 let response = client
91 .get(format!("{base_url}{path}"))
92 .headers(headers.clone())
93 .send()
94 .await
95 .unwrap_or_else(|e| panic!("request to {path} failed: {e}"));
96 if response.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
97 return response;
98 }
99 assert!(
100 Instant::now() < deadline,
101 "{path} still returned 503 after {timeout:?}"
102 );
103 cda_interfaces::util::tokio_ext::sleep_for(Duration::from_millis(100)).await;
104 }
105}
106
107/// Polls `path` until the runtime update's `409 Update in progress` protection
108/// is lifted, and returns that first unprotected response.
109///
110/// The protection outlives the execution's `completed` status. The update task
111/// publishes the status, then settles the transport, then drops the protection,
112/// so the first non-409 response is the one that carries the post-update
113/// communication state.
114async fn wait_until_update_protection_lifted(
115 base_url: &str,
116 path: &str,
117 headers: &HeaderMap,
118 timeout: Duration,
119) -> reqwest::Response {
120 let client = reqwest::Client::new();
121 let deadline = Instant::now()
122 .checked_add(timeout)
123 .expect("Timeout is too large");
124 loop {
125 let response = client
126 .get(format!("{base_url}{path}"))
127 .headers(headers.clone())
128 .send()
129 .await
130 .unwrap_or_else(|e| panic!("request to {path} failed: {e}"));
131 if response.status() != reqwest::StatusCode::CONFLICT {
132 return response;
133 }
134 assert!(
135 Instant::now() < deadline,
136 "{path} still returned 409 (update in progress) after {timeout:?}"
137 );
138 cda_interfaces::util::tokio_ext::sleep_for(Duration::from_millis(100)).await;
139 }
140}
141
142/// Reads the `Retry-After` header as whole seconds, or `None` when it is absent
143/// or not a plain seconds value.
144fn retry_after_seconds(response: &reqwest::Response) -> Option<u64> {
145 response
146 .headers()
147 .get(reqwest::header::RETRY_AFTER)
148 .and_then(|v| v.to_str().ok())
149 .and_then(|s| s.parse().ok())
150}
151
152fn base_url(runtime: &crate::util::runtime::TestRuntime) -> String {
153 format!(
154 "http://{}:{}",
155 runtime.config.server.address(),
156 runtime.config.server.port()
157 )
158}
159
160/// On-demand initialization for an authenticated diagnostic request. The gate
161/// returns 503 with `Retry-After` immediately and fires the activation trigger
162/// in the background. The endpoint leaves the 503 state once that completes.
[docs]163/// [[ itest~deferred-on-demand-pending, On-demand requests report pending before activation completes, itest ]]
164#[tokio::test]
165async fn on_demand_diagnostic_path_returns_503_then_200() {
166 let (runtime, _guard) = setup_integration_test(true)
167 .await
168 .expect("Failed to setup runtime");
169 with_temporary_cda(
170 &runtime.config,
171 CdaMode::Deferred(PostUpdateCommunicationMode::Enabled).config(runtime),
172 || async {},
173 || async {
174 let client = reqwest::Client::new();
175 let base = base_url(runtime);
176 let headers = auth_header(&runtime.config, None)
177 .await
178 .expect("Failed to authenticate");
179
180 let version_response = client
181 .get(format!("{base}/vehicle/v15/apps/sovd2uds/data/version"))
182 .send()
183 .await
184 .expect("Version request failed");
185 assert_eq!(
186 version_response.status(),
187 reqwest::StatusCode::OK,
188 "non-ECU endpoints must remain available while communication is deferred"
189 );
190
191 // The request must return 503 immediately rather than block through
192 // the full activation sequence.
193 let response = client
194 .get(format!("{base}{}", flxc1000_data_path()))
195 .headers(headers.clone())
196 .send()
197 .await
198 .expect("Request failed");
199
200 assert_eq!(
201 response.status(),
202 reqwest::StatusCode::SERVICE_UNAVAILABLE,
203 "Expected 503 before the background activation trigger completes"
204 );
205
206 // Retry-After must equal the configured value.
207 assert_eq!(
208 retry_after_seconds(&response),
209 Some(1),
210 "Retry-After should be 1 second"
211 );
212
213 let body_text = response.text().await.expect("Failed to read response body");
214 let body: ApiErrorResponse<String> = serde_json::from_str(&body_text)
215 .unwrap_or_else(|e| panic!("failed to parse error body: {e}\nbody: {body_text}"));
216 assert_eq!(body.error_code, ErrorCode::VendorSpecific);
217 assert_eq!(body.vendor_code.as_deref(), Some("communication-not-ready"));
218
219 // The gate's own request fired the trigger. Poll until it completes.
220 let response = wait_until_not_pending(
221 &base,
222 &flxc1000_data_path(),
223 &headers,
224 Duration::from_secs(30),
225 )
226 .await;
227 assert_eq!(
228 response.status(),
229 reqwest::StatusCode::OK,
230 "endpoint must return 200 once the background activation completes"
231 );
232 },
233 )
234 .await;
235}
236
237/// The CDA sends no diagnostic request to the ECU until an authenticated
238/// diagnostic request authorizes the first activation.
[docs]239/// [[ itest~deferred-on-demand-uds-silence, On-demand startup sends no UDS request before an authorized trigger, itest ]]
240#[tokio::test]
241async fn on_demand_trigger_produces_no_doip_traffic_before_authorized_request() {
242 if skip_for_can(
243 "on_demand_trigger_produces_no_doip_traffic_before_authorized_request",
244 "asserts on recorded DoIP frames",
245 ) {
246 return;
247 }
248 // Set up without starting the CDA, so recording can start before it exists.
249 let (runtime, _guard) = setup_integration_test_without_cda(true)
250 .await
251 .expect("Failed to setup runtime");
252 with_temporary_cda(
253 &runtime.config,
254 CdaMode::Deferred(PostUpdateCommunicationMode::Enabled).config(runtime),
255 || async {
256 // Record from before the CDA starts, to catch startup traffic.
257 ecusim::start_recording(&runtime.ecu_sim, ECU_SIM_NAME)
258 .await
259 .expect("Failed to start recording");
260 },
261 || async {
262 // The CDA is started. Check for traffic before any authenticated
263 // request.
264 let recorded_frames = ecusim::stop_and_clear_recording(&runtime.ecu_sim, ECU_SIM_NAME)
265 .await
266 .expect("Failed to stop recording");
267
268 // No diagnostic request yet, so it must be silent on the network.
269 assert!(
270 recorded_frames.is_empty(),
271 "Unexpected DoIP traffic before the authorized trigger: {recorded_frames:?}",
272 );
273
274 // Record again across the trigger, so that a recorder which never
275 // captures anything cannot satisfy the assertion above.
276 ecusim::start_recording(&runtime.ecu_sim, ECU_SIM_NAME)
277 .await
278 .expect("Failed to restart recording");
279
280 // The first authenticated diagnostic request is the only authorized
281 // trigger. It fires the activation in the background, so poll until
282 // the guard lifts.
283 let base = base_url(runtime);
284 let headers = auth_header(&runtime.config, None)
285 .await
286 .expect("Failed to authenticate");
287 let response = wait_until_not_pending(
288 &base,
289 &flxc1000_data_path(),
290 &headers,
291 Duration::from_secs(30),
292 )
293 .await;
294 assert_eq!(
295 response.status(),
296 reqwest::StatusCode::OK,
297 "endpoint must return 200 once the background activation completes"
298 );
299
300 // The same recorder that saw nothing before must now see traffic.
301 let recorded_frames = ecusim::stop_and_clear_recording(&runtime.ecu_sim, ECU_SIM_NAME)
302 .await
303 .expect("Failed to stop recording");
304 assert!(
305 !recorded_frames.is_empty(),
306 "expected DoIP traffic after the authorized trigger, but the recording was empty"
307 );
308 },
309 )
310 .await;
311}
312
313/// Verifies that after a runtime update with `PostUpdateCommunicationMode::Deferred`,
314/// diagnostic endpoints return 503 again until re-triggered.
315///
316/// The update takes the transport down through an exclusive disable lease. Under
317/// `Deferred` that lease is dropped rather than released, so communication
318/// returns to the state it was in before the first trigger.
319///
320/// The sequence is:
321/// a. Start a CDA in deferred mode with `PostUpdateCommunicationMode::Deferred`.
322/// b. Trigger initialization (transition away from the 503 state).
323/// c. Perform a runtime update (upload an MDD to `runtimefiles-nextupdate`,
324/// then run an `Apply` execution and wait for the reload cycle).
325/// d. Verify diagnostic endpoints return 503 again after the update.
326/// e. Trigger initialization again (exits the 503 state again).
[docs]327/// [[ itest~deferred-post-update, Deferred post-update mode requires communication reactivation, itest ]]
328#[tokio::test]
329async fn post_update_deferred_mode_returns_503_until_triggered() {
330 let (runtime, _guard) = setup_integration_test(true)
331 .await
332 .expect("Failed to setup runtime");
333
334 // Step a: deferred mode with PostUpdateCommunicationMode::Deferred, and the
335 // default plugin, so the first request triggers initialization.
336 with_temporary_cda(
337 &runtime.config,
338 CdaMode::Deferred(PostUpdateCommunicationMode::Deferred).config(runtime),
339 || async {},
340 || async {
341 let base = base_url(runtime);
342 let headers = auth_header(&runtime.config, None)
343 .await
344 .expect("Failed to authenticate");
345
346 // Step b: trigger initialization by sending an authenticated diagnostic
347 // request and waiting until the guard exits the 503 state.
348 let response = wait_until_not_pending(
349 &base,
350 &flxc1000_data_path(),
351 &headers,
352 Duration::from_secs(30),
353 )
354 .await;
355 assert_eq!(
356 response.status(),
357 reqwest::StatusCode::OK,
358 "endpoint must return 200 once the background activation completes"
359 );
360
361 // Step c: perform a runtime update. Mutating runtime files needs a
362 // vehicle lock.
363 let lock_id = runtimefiles::setup_with_lock(&runtime.config, &headers).await;
364
365 // The update starts from the running databases, so re-uploading
366 // one of them keeps this update from changing the "vehicle".
367 let response = runtimefiles::upload_mdd(&runtime.config, &headers).await;
368 assert_eq!(
369 response.status(),
370 reqwest::StatusCode::CREATED,
371 "Failed to upload the database for the update"
372 );
373
374 runtimefiles::execute_mode(&runtime.config, &headers, ExecutionMode::Apply)
375 .await
376 .expect("Apply execution failed");
377
378 // Step d: the update dropped the disable lease instead of releasing
379 // it, so the diagnostic path answers 503 again. A non-deferred
380 // post-update mode would have served 200 here.
381 let response = wait_until_update_protection_lifted(
382 &base,
383 &flxc1000_data_path(),
384 &headers,
385 Duration::from_secs(30),
386 )
387 .await;
388 let status = response.status();
389 let retry_after = retry_after_seconds(&response);
390 let body = response.text().await.unwrap_or_default();
391 assert_eq!(
392 status,
393 reqwest::StatusCode::SERVICE_UNAVAILABLE,
394 "diagnostic endpoint must return 503 again after a Deferred post-update, body: \
395 {body}"
396 );
397 assert_eq!(retry_after, Some(1), "Retry-After should be 1 second");
398
399 // Step e: that request fired the trigger again. Poll until it
400 // completes.
401 let response = wait_until_not_pending(
402 &base,
403 &flxc1000_data_path(),
404 &headers,
405 Duration::from_secs(30),
406 )
407 .await;
408 assert_eq!(
409 response.status(),
410 reqwest::StatusCode::OK,
411 "endpoint must return 200 once the post-update activation completes"
412 );
413
414 // Reset the staging collection so the next test starts from "no
415 // pending changes".
416 runtimefiles::execute_mode(&runtime.config, &headers, ExecutionMode::Cleanup)
417 .await
418 .expect("Cleanup execution failed");
419 wait_until_update_protection_lifted(
420 &base,
421 &flxc1000_data_path(),
422 &headers,
423 Duration::from_secs(30),
424 )
425 .await;
426 // The rest of the shared suite runs against this instance, so the
427 // whole vehicle has to survive the update.
428 wait_for_ecus_online(&runtime.config)
429 .await
430 .expect("ECUs did not come back online after the update cycle");
431 runtimefiles::teardown_lock(&runtime.config, &headers, &lock_id).await;
432 },
433 )
434 .await;
435}
436
437/// In `init_mode = Disabled` an ordinary authenticated diagnostic request
438/// must never trigger activation or send a diagnostic request to the ECU.
[docs]439/// [[ itest~deferred-disabled-uds-silence, Disabled mode rejects request activation and sends no UDS request, itest ]]
440#[tokio::test]
441async fn disabled_mode_never_activates_or_produces_traffic() {
442 if skip_for_can(
443 "disabled_mode_never_activates_or_produces_traffic",
444 "asserts on recorded DoIP frames",
445 ) {
446 return;
447 }
448
449 let (runtime, _guard) = setup_integration_test_without_cda(true)
450 .await
451 .expect("Failed to setup runtime");
452 with_temporary_cda(
453 &runtime.config,
454 CdaMode::Disabled.config(runtime),
455 || async {
456 // Record from before the CDA starts, to catch startup traffic.
457 ecusim::start_recording(&runtime.ecu_sim, ECU_SIM_NAME)
458 .await
459 .expect("Failed to start recording");
460 },
461 || async {
462 // Nothing is authorized to bring the vehicle network up, so the CDA
463 // must be as silent at startup as an OnDemand instance.
464 let recorded_frames = ecusim::stop_and_clear_recording(&runtime.ecu_sim, ECU_SIM_NAME)
465 .await
466 .expect("Failed to stop recording");
467 assert!(
468 recorded_frames.is_empty(),
469 "Unexpected DoIP traffic before any request under Disabled: {recorded_frames:?}",
470 );
471
472 ecusim::start_recording(&runtime.ecu_sim, ECU_SIM_NAME)
473 .await
474 .expect("Failed to restart recording");
475
476 let base = base_url(runtime);
477 let headers = auth_header(&runtime.config, None)
478 .await
479 .expect("Failed to authenticate");
480 let client = reqwest::Client::new();
481
482 // OnDemand resolves to 200 well inside this window. Disabled must
483 // never leave the pending state.
484 let deadline = Instant::now()
485 .checked_add(Duration::from_secs(3))
486 .expect("deadline does not overflow");
487 while Instant::now() < deadline {
488 let response = client
489 .get(format!("{base}{}", flxc1000_data_path()))
490 .headers(headers.clone())
491 .send()
492 .await
493 .expect("request failed");
494 assert_eq!(
495 response.status(),
496 reqwest::StatusCode::SERVICE_UNAVAILABLE,
497 "Disabled must never authorize activation from an ordinary request"
498 );
499 cda_interfaces::util::tokio_ext::sleep_for(Duration::from_millis(200)).await;
500 }
501
502 // Every rejected request must also have produced zero
503 // vehicle-network traffic.
504 let recorded_frames = ecusim::stop_and_clear_recording(&runtime.ecu_sim, ECU_SIM_NAME)
505 .await
506 .expect("Failed to stop recording");
507 assert!(
508 recorded_frames.is_empty(),
509 "Disabled must produce zero vehicle-network traffic, got: {recorded_frames:?}",
510 );
511 },
512 )
513 .await;
514}
515
516/// Reads the SOVD variant state for `ecu_endpoint` (an authenticated `GET`).
517async fn ecu_variant_state(
518 config: &opensovd_cda_lib::config::configfile::Configuration,
519 headers: &HeaderMap,
520 ecu_endpoint: &str,
521) -> sovd_interfaces::components::ecu::State {
522 let response = send_cda_request(
523 config,
524 ecu_endpoint,
525 http::StatusCode::OK,
526 http::Method::GET,
527 None,
528 Some(headers),
529 None,
530 )
531 .await
532 .expect("Failed to get ecu component");
533 let ecu: sovd_interfaces::components::ecu::get::Response =
534 response_to_t(&response).expect("Failed to parse ecu component response");
535 ecu.variant.state
536}
537
538/// Polls `ecu_endpoint`'s variant state until it matches `expected`, or
539/// panics once `timeout` elapses.
540///
541/// Not `wait_for_ecus_online`, which waits for `networkstructure` to reach
542/// `"Online"` or `"Duplicate"`. That only happens once a variant has been
543/// detected, so under `variant_detection = Never` it would hang.
544async fn wait_for_ecu_variant_state(
545 config: &opensovd_cda_lib::config::configfile::Configuration,
546 headers: &HeaderMap,
547 ecu_endpoint: &str,
548 expected: sovd_interfaces::components::ecu::State,
549 timeout: Duration,
550) -> sovd_interfaces::components::ecu::State {
551 let deadline = Instant::now()
552 .checked_add(timeout)
553 .expect("deadline does not overflow");
554 loop {
555 let state = ecu_variant_state(config, headers, ecu_endpoint).await;
556 if state == expected {
557 return state;
558 }
559 assert!(
560 Instant::now() < deadline,
561 "{ecu_endpoint} did not reach {expected:?} within {timeout:?}, last state: {state:?}"
562 );
563 cda_interfaces::util::tokio_ext::sleep_for(Duration::from_millis(200)).await;
564 }
565}
566
567/// Explicitly triggers per-ECU variant detection for `ecu_endpoint` (an
568/// authenticated `PUT`, handled by `UdsVariant::detect_variant`).
569///
570/// A direct UDS-level probe against one ECU, not
571/// `CommunicationPlugin::trigger_detection()`, which has no HTTP-reachable path
572/// yet. `variant_detection` does not gate it, because it only controls the
573/// automatic whole-vehicle variant detection run as part of an activation.
574async fn trigger_variant_detection(
575 config: &opensovd_cda_lib::config::configfile::Configuration,
576 headers: &HeaderMap,
577 ecu_endpoint: &str,
578) {
579 send_cda_request(
580 config,
581 ecu_endpoint,
582 http::StatusCode::CREATED,
583 http::Method::PUT,
584 None,
585 Some(headers),
586 None,
587 )
588 .await
589 .expect("Failed to trigger variant detection");
590}
591
592/// `variant_detection = Never` must still bring the transport up under `Always`
593/// `init_mode`, but must never settle any ECU's variant via the automatic
594/// whole-vehicle variant detection. A manual per-ECU trigger (see
595/// [`trigger_variant_detection`]) still works, because it bypasses that path.
[docs]596/// [[ itest~variant-detection-explicit, Disabled automatic variant detection permits an explicit ECU trigger, itest ]]
597#[tokio::test]
598async fn variant_detection_never_requires_explicit_trigger() {
599 let (runtime, _guard) = setup_integration_test(true)
600 .await
601 .expect("Failed to setup runtime");
602
603 let outcome = AssertUnwindSafe(async {
604 restart_cda_with_config(&runtime.config, |config| {
605 config.communication = CommunicationSettings {
606 init_mode: CommunicationInitMode::Always,
607 variant_detection: VariantDetectionMode::Never,
608 post_update_mode: PostUpdateCommunicationMode::Enabled,
609 deferred_retry_after_seconds: 1,
610 };
611 })
612 .await
613 .expect("Failed to start CDA with variant_detection = Never");
614
615 let headers = auth_header(&runtime.config, None)
616 .await
617 .expect("Failed to authenticate");
618
619 // `Connectivity` only records that an ECU actually answered:
620 // - over `DoIP` the gateway announces its ECUs, so connectivity reaches
621 // Online with the ECU still NotTested.
622 // - over CAN nothing announces anything and no exchange happens under
623 // `Never`, so the ECU stays Offline until the manual trigger below.
624 //
625 // Either way the ECU must never reach Online, which needs a detected
626 // variant. A closure rather than a binding, because `State` is neither
627 // `Copy` nor `Clone` and both uses below need the value.
628 let undetected_state = || {
629 if use_can() {
630 sovd_interfaces::components::ecu::State::Offline
631 } else {
632 sovd_interfaces::components::ecu::State::NotTested
633 }
634 };
635 wait_for_ecu_variant_state(
636 &runtime.config,
637 &headers,
638 ECU_FLXC1000_ENDPOINT,
639 undetected_state(),
640 Duration::from_secs(10),
641 )
642 .await;
643
644 // Give the absent detector the window it would have had under Always,
645 // then confirm the state never moved on its own.
646 cda_interfaces::util::tokio_ext::sleep_for(Duration::from_secs(2)).await;
647 let state = ecu_variant_state(&runtime.config, &headers, ECU_FLXC1000_ENDPOINT).await;
648 assert_eq!(
649 state,
650 undetected_state(),
651 "variant_detection = Never must not auto-detect a variant"
652 );
653
654 // A manual per-ECU trigger bypasses the gated variant detection and
655 // must settle the variant as it would under Always.
656 trigger_variant_detection(&runtime.config, &headers, ECU_FLXC1000_ENDPOINT).await;
657 let state = ecu_variant_state(&runtime.config, &headers, ECU_FLXC1000_ENDPOINT).await;
658 assert_eq!(
659 state,
660 sovd_interfaces::components::ecu::State::Online,
661 "a manual per-ECU trigger must still settle the variant under variant_detection = \
662 Never"
663 );
664 })
665 .catch_unwind()
666 .await;
667
668 restart_cda(&runtime.config)
669 .await
670 .expect("Failed to restore normal CDA");
671 if let Err(panic) = outcome {
672 std::panic::resume_unwind(panic);
673 }
674}