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}