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 std::sync::Arc;
 15
 16use aide::axum::{ApiRouter, routing};
 17use axum::{Json, http::StatusCode};
 18use cda_sovd::dynamic_router::DynamicRouter;
 19use futures::FutureExt;
 20use opensovd_cda_lib::{cda_version, config::configfile::ServerTransport};
 21use reqwest::Method;
 22use serde::{Deserialize, Serialize};
 23
 24use crate::util::{
 25    TestingError,
 26    http::{Response, response_to_t},
 27    runtime::{find_available_tcp_port, host, wait_for_cda_online},
 28};
 29
 30const MAIN_HEALTH_COMPONENT_KEY: &str = "main";
 31
 32#[derive(Serialize, Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq)]
 33struct TestData {
 34    oem_name: String,
 35    version: String,
 36}
 37
 38async fn add_custom_routes(dynamic_router: &DynamicRouter) {
 39    let custom_router = ApiRouter::new().api_route(
 40        "/test",
 41        routing::get_with(
 42            || async {
 43                (
 44                    StatusCode::OK,
 45                    Json(TestData {
 46                        oem_name: "Eclipse Foundation".to_string(),
 47                        version: "1.0.0".to_string(),
 48                    }),
 49                )
 50            },
 51            |op| {
 52                // OpenAPI documentation for the GET /demo endpoint
 53                op.description("Get demo data")
 54                    .response_with::<200, Json<TestData>, _>(|res| {
 55                        res.example(TestData {
 56                            oem_name: "Eclipse Foundation".to_string(),
 57                            version: "1.0.0".to_string(),
 58                        })
 59                    })
 60            },
 61        )
 62        .post_with(
 63            |Json(payload): Json<TestData>| async move {
 64                // Echo back the payload
 65                (StatusCode::CREATED, Json(payload))
 66            },
 67            |op| {
 68                op.description("Create demo data")
 69                    .response_with::<201, Json<TestData>, _>(|res| {
 70                        res.description("Successfully created")
 71                    })
 72            },
 73        ),
 74    );
 75
 76    dynamic_router.add_routes(custom_router).await;
 77}
 78
 79fn shutdown_channel() -> (
 80    tokio::sync::broadcast::Sender<()>,
 81    impl Future<Output = ()> + Clone + Send + 'static,
 82) {
 83    let (tx, mut rx) = tokio::sync::broadcast::channel::<()>(1);
 84    let signal = async move {
 85        rx.recv().await.ok();
 86    }
 87    .shared();
 88    (tx, signal)
 89}
 90
 91async fn shutdown_and_join(
 92    shutdown_tx: tokio::sync::broadcast::Sender<()>,
 93    webserver_join_handle: tokio::task::JoinHandle<()>,
 94) {
 95    shutdown_tx.send(()).ok();
 96    webserver_join_handle
 97        .await
 98        .expect("Failed to shutdown webserver");
 99}
100
101/// Exercises the `/test` demo endpoint via `get`/`post` (parameterized so
102/// callers can send the requests over TCP or a Unix domain socket) and
103/// asserts on the round-tripped `TestData` payloads.
104async fn assert_demo_endpoint_get_post<GetFut>(
105    get: impl FnOnce() -> GetFut,
106    post: impl FnOnce(String) -> futures::future::BoxFuture<'static, Result<Response, TestingError>>,
107) where
108    GetFut: Future<Output = Result<Response, TestingError>>,
109{
110    let get_response = get().await.expect("GET request failed");
111    let demo_data: TestData = response_to_t(&get_response).expect("Failed to parse GET response");
112    assert_eq!(demo_data.oem_name, "Eclipse Foundation");
113    assert_eq!(demo_data.version, "1.0.0");
114
115    let post_payload = TestData {
116        oem_name: "Custom OEM".to_string(),
117        version: "2.0.0".to_string(),
118    };
119    let post_body = serde_json::to_string(&post_payload).expect("Failed to serialize payload");
120    let post_response = post(post_body).await.expect("POST request failed");
121
122    let response_data: TestData =
123        response_to_t(&post_response).expect("Failed to parse POST response");
124    assert_eq!(response_data, post_payload);
125}
126
127#[tokio::test]
128async fn test_custom_demo_endpoint() {
129    // Use loopback since we don't need actual ECU connections for this test
130    let host = host();
131    let test_port = find_available_tcp_port(&host).expect("Failed to find available port");
132
133    let webserver_config = cda_sovd::WebServerConfig::Tcp {
134        host: host.clone(),
135        port: test_port,
136    };
137
138    let (shutdown_tx, shutdown_signal) = shutdown_channel();
139
140    let (dynamic_router, webserver_join_handle) =
141        cda_sovd::launch_webserver(webserver_config, shutdown_signal.clone())
142            .await
143            .expect("Failed to launch webserver");
144
145    let health = cda_health::add_health_routes(&dynamic_router, cda_version().to_owned()).await;
146    let main_health_provider = {
147        let provider = Arc::new(cda_health::StatusHealthProvider::new(
148            cda_health::Status::Starting,
149        ));
150        health
151            .register_provider(
152                MAIN_HEALTH_COMPONENT_KEY,
153                Arc::clone(&provider) as Arc<dyn cda_health::HealthStatus>,
154            )
155            .await
156            .expect("Failed to register main health provider");
157        provider
158    };
159
160    // Add custom routes directly. No vehicle routes are needed for this test.
161    add_custom_routes(&dynamic_router).await;
162
163    main_health_provider
164        .update_status(cda_health::Status::Up)
165        .await;
166
167    let url = reqwest::Url::parse(&format!("http://{host}:{test_port}/test")).expect("Invalid URL");
168    wait_for_cda_online(&ServerTransport::Tcp {
169        address: host,
170        port: test_port,
171        unix_socket: None,
172    })
173    .await
174    .expect("Webserver did not start in time");
175
176    assert_demo_endpoint_get_post(
177        || crate::util::http::send_request(StatusCode::OK, Method::GET, None, None, url.clone()),
178        |body| {
179            let url = url.clone();
180            async move {
181                crate::util::http::send_request(
182                    StatusCode::CREATED,
183                    Method::POST,
184                    Some(&body),
185                    None,
186                    url,
187                )
188                .await
189            }
190            .boxed()
191        },
192    )
193    .await;
194
195    shutdown_and_join(shutdown_tx, webserver_join_handle).await;
196}
197
[docs]198/// [[ itest~sovd-api-http-server-unix-socket, End-to-end GET/POST over a Unix domain socket bound webserver, itest ]]
199#[cfg(unix)]
200#[tokio::test]
201async fn test_custom_demo_endpoint_over_unix_socket() {
202    let socket_dir = tempfile::tempdir().expect("Failed to create temp dir");
203    let socket_path = socket_dir
204        .path()
205        .join("cda.sock")
206        .to_string_lossy()
207        .to_string();
208
209    let webserver_config = cda_sovd::WebServerConfig::UnixSocket {
210        path: socket_path.clone(),
211    };
212
213    let (shutdown_tx, shutdown_signal) = shutdown_channel();
214
215    let (dynamic_router, webserver_join_handle) =
216        cda_sovd::launch_webserver(webserver_config, shutdown_signal.clone())
217            .await
218            .expect("Failed to launch webserver");
219
220    // Add custom routes directly. No vehicle routes or health checks are
221    // needed for this test since we talk to the raw socket directly instead
222    // of polling the health endpoint for readiness.
223    add_custom_routes(&dynamic_router).await;
224
225    assert_demo_endpoint_get_post(
226        || {
227            crate::util::http::send_unix_socket_request(
228                &socket_path,
229                "/test",
230                StatusCode::OK,
231                Method::GET,
232                None,
233            )
234        },
235        |body| {
236            let socket_path = socket_path.clone();
237            async move {
238                crate::util::http::send_unix_socket_request(
239                    &socket_path,
240                    "/test",
241                    StatusCode::CREATED,
242                    Method::POST,
243                    Some(&body),
244                )
245                .await
246            }
247            .boxed()
248        },
249    )
250    .await;
251
252    shutdown_and_join(shutdown_tx, webserver_join_handle).await;
253}