LCOV - code coverage report
Current view: top level - dashql-native-proxy/src/test - proxy_grpc_test.rs Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 284 284
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : //! The gRPC proxy through the routes the app calls, against the mock of the test service.
       2              : 
       3              : use std::net::SocketAddr;
       4              : use std::time::Duration;
       5              : 
       6              : use http::{Request, Response};
       7              : use prost::Message;
       8              : 
       9              : use crate::proto::dashql_test::{TestServerStreamingRequest, TestServerStreamingResponse, TestUnaryRequest, TestUnaryResponse};
      10              : use crate::route_proxy_request;
      11              : use crate::test::grpc_service_mock::{spawn_grpc_test_service_mock, GrpcServiceMock};
      12              : 
      13              : const UNARY: &str = "/dashql.test.TestService/TestUnary";
      14              : const STREAMING: &str = "/dashql.test.TestService/TestServerStreaming";
      15              : const GUARD: Duration = Duration::from_secs(10);
      16              : 
      17           51 : fn request(method: &str, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Request<Vec<u8>> {
      18           51 :     let mut builder = Request::builder().method(method).uri(format!("dashql-native://localhost{path}"));
      19           73 :     for (name, value) in headers {
      20           73 :         builder = builder.header(*name, *value);
      21           73 :     }
      22           51 :     builder.body(body.to_vec()).unwrap()
      23           51 : }
      24              : 
      25           49 : fn header<'a>(response: &'a Response<Vec<u8>>, name: &str) -> Option<&'a str> {
      26           49 :     response.headers().get(name).map(|value| value.to_str().unwrap())
      27           49 : }
      28              : 
      29           20 : fn error(response: &Response<Vec<u8>>) -> serde_json::Value {
      30           20 :     assert_eq!(header(response, "dashql-error"), Some("true"), "{response:?}");
      31           20 :     serde_json::from_slice(response.body()).unwrap()
      32           20 : }
      33              : 
      34           11 : async fn create_channel(address: SocketAddr) -> String {
      35           11 :     let endpoint = format!("http://{address}");
      36           11 :     let response = route_proxy_request(request("POST", "/grpc/channels", &[("dashql-endpoint", endpoint.as_str())], &[])).await;
      37           11 :     assert_eq!(response.status(), 200, "{response:?}");
      38           11 :     header(&response, "dashql-channel-id").unwrap().to_string()
      39           11 : }
      40              : 
      41            5 : async fn start_stream(channel: &str, extra: &[(&str, &str)]) -> Response<Vec<u8>> {
      42            5 :     let mut headers = vec![("dashql-path", STREAMING)];
      43            5 :     headers.extend_from_slice(extra);
      44            5 :     let body = TestServerStreamingRequest { data: "request".to_string() }.encode_to_vec();
      45            5 :     route_proxy_request(request("POST", &format!("/grpc/channel/{channel}/streams"), &headers, &body)).await
      46            5 : }
      47              : 
      48            5 : fn stream_id(response: &Response<Vec<u8>>) -> String {
      49            5 :     assert_eq!(response.status(), 200, "{response:?}");
      50            5 :     header(response, "dashql-stream-id").unwrap().to_string()
      51            5 : }
      52              : 
      53           10 : async fn read_stream(channel: &str, stream: &str, read_ms: &str, batch_ms: &str, bytes: &str) -> Response<Vec<u8>> {
      54           10 :     let headers = [("dashql-read-timeout", read_ms), ("dashql-batch-timeout", batch_ms), ("dashql-batch-bytes", bytes)];
      55           10 :     route_proxy_request(request("GET", &format!("/grpc/channel/{channel}/stream/{stream}"), &headers, &[])).await
      56           10 : }
      57              : 
      58              : /// The messages of a batch, each with its length as a little endian prefix
      59            3 : fn messages(body: &[u8]) -> Vec<String> {
      60            3 :     let mut messages = Vec::new();
      61            3 :     let mut position = 0;
      62            7 :     while position < body.len() {
      63            4 :         let length = u32::from_le_bytes(body[position..position + 4].try_into().unwrap()) as usize;
      64            4 :         position += 4;
      65            4 :         messages.push(TestServerStreamingResponse::decode(&body[position..position + length]).unwrap().data);
      66            4 :         position += length;
      67            4 :     }
      68            3 :     messages
      69            3 : }
      70              : 
      71           20 : fn message(data: &str) -> Result<TestServerStreamingResponse, tonic::Status> {
      72           20 :     Ok(TestServerStreamingResponse { data: data.to_string() })
      73           20 : }
      74              : 
      75              : #[tokio::test]
      76            1 : async fn a_channel_needs_an_endpoint() {
      77            1 :     let response = route_proxy_request(request("POST", "/grpc/channels", &[], &[])).await;
      78            1 :     assert_eq!(response.status(), 400);
      79            1 :     let body = error(&response);
      80            1 :     assert_eq!(body["message"], "header is missing");
      81            1 :     assert_eq!(body["details"]["header"], "dashql-endpoint");
      82            1 : }
      83              : 
      84              : #[tokio::test]
      85            1 : async fn rejects_an_invalid_endpoint() {
      86            1 :     let response = route_proxy_request(request("POST", "/grpc/channels", &[("dashql-endpoint", "not a url")], &[])).await;
      87            1 :     assert_eq!(error(&response)["message"], "header is not a valid endpoint");
      88            1 : }
      89              : 
      90              : #[tokio::test]
      91            1 : async fn reports_an_endpoint_that_cannot_be_reached() {
      92            1 :     let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
      93            1 :     let endpoint = format!("http://{}", listener.local_addr().unwrap());
      94            1 :     drop(listener);
      95            1 :     let response = route_proxy_request(request("POST", "/grpc/channels", &[("dashql-endpoint", endpoint.as_str())], &[])).await;
      96            1 :     assert_eq!(error(&response)["message"], "connecting to gRPC endpoint failed");
      97            1 : }
      98              : 
      99              : #[tokio::test]
     100            1 : async fn a_client_certificate_needs_its_key_and_the_key_its_certificate() {
     101            1 :     let endpoint = ("dashql-endpoint", "https://127.0.0.1:1");
     102            1 :     let response = route_proxy_request(request("POST", "/grpc/channels", &[endpoint, ("dashql-tls-client-cert", "/cert.pem")], &[])).await;
     103            1 :     assert_eq!(error(&response)["details"]["header"], "dashql-tls-client-key");
     104            1 :     let response = route_proxy_request(request("POST", "/grpc/channels", &[endpoint, ("dashql-tls-client-key", "/key.pem")], &[])).await;
     105            1 :     assert_eq!(error(&response)["details"]["header"], "dashql-tls-client-cert");
     106            1 : }
     107              : 
     108              : #[tokio::test]
     109            1 : async fn reports_tls_files_that_cannot_be_read() {
     110            1 :     let endpoint = ("dashql-endpoint", "https://127.0.0.1:1");
     111            2 :     for headers in [
     112            1 :         vec![endpoint, ("dashql-tls-cacerts", "/missing/ca.pem")],
     113            1 :         vec![endpoint, ("dashql-tls-client-cert", "/missing/cert.pem"), ("dashql-tls-client-key", "/missing/key.pem")],
     114            1 :     ] {
     115            2 :         let body = error(&route_proxy_request(request("POST", "/grpc/channels", &headers, &[])).await);
     116            2 :         assert_eq!(body["message"], "gRPC tls config is invalid");
     117            2 :         assert!(body["details"]["error"].as_str().unwrap().contains("/missing/"), "{body}");
     118            1 :     }
     119            1 : }
     120              : 
     121              : #[tokio::test]
     122            1 : async fn tls_without_a_server_fails_to_connect() {
     123            1 :     let headers = [("dashql-endpoint", "https://127.0.0.1:1"), ("dashql-tls", "1")];
     124            1 :     let body = error(&route_proxy_request(request("POST", "/grpc/channels", &headers, &[])).await);
     125            1 :     assert_eq!(body["message"], "connecting to gRPC endpoint failed");
     126            1 : }
     127              : 
     128              : #[tokio::test]
     129            1 : async fn calls_a_unary_function() {
     130            1 :     let (mock, mut setup_unary, _setup_streaming) = GrpcServiceMock::new();
     131            1 :     let metadata = mock.received_metadata.clone();
     132            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     133            1 :     let channel = create_channel(address).await;
     134              : 
     135            1 :     let server = tokio::spawn(async move {
     136            1 :         let (param, sender) = setup_unary.recv().await.unwrap();
     137            1 :         sender.send(Ok(TestUnaryResponse { data: "response".to_string() })).await.unwrap();
     138            1 :         param
     139            1 :     });
     140            1 :     let body = TestUnaryRequest { data: "request".to_string() }.encode_to_vec();
     141            1 :     let response = route_proxy_request(request("POST", &format!("/grpc/channel/{channel}/unary"), &[
     142            1 :         ("dashql-path", UNARY),
     143            1 :         ("authorization", "Bearer token"),
     144            1 :         ("origin", "http://localhost"),
     145            1 :         ("accept", "*/*"),
     146            1 :     ], &body)).await;
     147              : 
     148            1 :     assert_eq!(response.status(), 200, "{response:?}");
     149            1 :     assert_eq!(header(&response, "dashql-channel-id"), Some(channel.as_str()));
     150            1 :     assert_eq!(TestUnaryResponse::decode(response.body().as_slice()).unwrap().data, "response");
     151            1 :     assert_eq!(server.await.unwrap().data, "request");
     152              : 
     153            1 :     let metadata = metadata.lock().unwrap()[0].clone();
     154            1 :     assert_eq!(metadata.get("authorization").unwrap(), "Bearer token");
     155              :     // Data Cloud refuses these, and our headers stop at the proxy
     156            3 :     for masked in ["origin", "accept", "dashql-path"] {
     157            3 :         assert!(metadata.get(masked).is_none(), "{masked}");
     158            1 :     }
     159            1 :     shutdown.send(()).unwrap();
     160            1 : }
     161              : 
     162              : #[tokio::test]
     163            1 : async fn forwards_the_rich_error_of_a_unary_function() {
     164            1 :     let (mock, mut setup_unary, _setup_streaming) = GrpcServiceMock::new();
     165            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     166            1 :     let channel = create_channel(address).await;
     167            1 :     tokio::spawn(async move {
     168            1 :         let (_, sender) = setup_unary.recv().await.unwrap();
     169            1 :         let status = tonic::Status::with_details(tonic::Code::InvalidArgument, "bad query", vec![1, 2, 3].into());
     170            1 :         sender.send(Err(status)).await.unwrap();
     171            1 :     });
     172            1 :     let response = route_proxy_request(request("POST", &format!("/grpc/channel/{channel}/unary"), &[("dashql-path", UNARY)], &[])).await;
     173            1 :     assert_eq!(response.status(), 400);
     174            1 :     assert_eq!(header(&response, "dashql-grpc-status"), Some("3"));
     175            1 :     assert_eq!(header(&response, "dashql-grpc-message"), Some("bad query"));
     176            1 :     assert_eq!(header(&response, "dashql-grpc-status-details-bin"), Some("AQID"));
     177            1 :     assert_eq!(error(&response)["message"], "gRPC call failed");
     178            1 :     shutdown.send(()).unwrap();
     179            1 : }
     180              : 
     181              : #[tokio::test]
     182            1 : async fn survives_a_database_error_that_spans_lines() {
     183            1 :     let (mock, mut setup_unary, _setup_streaming) = GrpcServiceMock::new();
     184            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     185            1 :     let channel = create_channel(address).await;
     186            1 :     tokio::spawn(async move {
     187            1 :         let (_, sender) = setup_unary.recv().await.unwrap();
     188            1 :         let status = tonic::Status::new(tonic::Code::InvalidArgument, "syntax error at line 1\nnear \"caf\u{e9}\"");
     189            1 :         sender.send(Err(status)).await.unwrap();
     190            1 :     });
     191            1 :     let response = route_proxy_request(request("POST", &format!("/grpc/channel/{channel}/unary"), &[("dashql-path", UNARY)], &[])).await;
     192            1 :     assert_eq!(response.status(), 400);
     193            1 :     assert_eq!(header(&response, "dashql-grpc-status"), Some("3"));
     194            1 :     assert!(error(&response)["details"]["error"].as_str().unwrap().contains("near"));
     195            1 :     shutdown.send(()).unwrap();
     196            1 : }
     197              : 
     198              : #[tokio::test]
     199            1 : async fn validates_a_call_before_sending_it() {
     200            1 :     let (mock, _setup_unary, _setup_streaming) = GrpcServiceMock::new();
     201            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     202            1 :     let channel = create_channel(address).await;
     203              : 
     204            2 :     for route in ["unary", "streams"] {
     205            2 :         let path = format!("/grpc/channel/{channel}/{route}");
     206            2 :         let body = error(&route_proxy_request(request("POST", &path, &[], &[])).await);
     207            2 :         assert_eq!(body["message"], "header is missing");
     208            2 :         let body = error(&route_proxy_request(request("POST", &path, &[("dashql-path", "no path")], &[])).await);
     209            2 :         assert_eq!(body["message"], "header stores invalid path");
     210            1 :     }
     211            1 :     shutdown.send(()).unwrap();
     212            1 : }
     213              : 
     214              : #[tokio::test]
     215            1 : async fn refuses_an_unknown_channel() {
     216            2 :     for route in ["unary", "streams"] {
     217            2 :         let response = route_proxy_request(request("POST", &format!("/grpc/channel/999999/{route}"), &[("dashql-path", UNARY)], &[])).await;
     218            2 :         assert_eq!(response.status(), 404);
     219            2 :         assert_eq!(error(&response)["message"], "gRPC channel id is unknown");
     220            1 :     }
     221            1 :     assert_eq!(read_stream("999999", "0", "1", "1", "1").await.status(), 404);
     222            1 : }
     223              : 
     224              : #[tokio::test]
     225            1 : async fn a_deleted_channel_is_gone() {
     226            1 :     let (mock, _setup_unary, _setup_streaming) = GrpcServiceMock::new();
     227            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     228            1 :     let channel = create_channel(address).await;
     229            1 :     assert_eq!(route_proxy_request(request("DELETE", &format!("/grpc/channel/{channel}"), &[], &[])).await.status(), 200);
     230            1 :     let response = route_proxy_request(request("POST", &format!("/grpc/channel/{channel}/unary"), &[("dashql-path", UNARY)], &[])).await;
     231            1 :     assert_eq!(response.status(), 404);
     232            1 :     shutdown.send(()).unwrap();
     233            1 : }
     234              : 
     235              : #[tokio::test]
     236            1 : async fn rejects_methods_a_route_does_not_have() {
     237            1 :     assert_eq!(route_proxy_request(request("GET", "/grpc/channels", &[], &[])).await.status(), 404);
     238            1 :     assert_eq!(route_proxy_request(request("GET", "/grpc/channel/1/unary", &[], &[])).await.status(), 404);
     239            1 : }
     240              : 
     241              : #[tokio::test]
     242            1 : async fn streams_batches_until_the_end() {
     243            1 :     let (mock, _setup_unary, mut setup_streaming) = GrpcServiceMock::new();
     244            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     245            1 :     let channel = create_channel(address).await;
     246              : 
     247            1 :     let server = tokio::spawn(async move {
     248            1 :         let (param, sender) = setup_streaming.recv().await.unwrap();
     249            1 :         sender.send(message("one")).await.unwrap();
     250            1 :         sender.send(message("two")).await.unwrap();
     251            1 :         param
     252            1 :     });
     253            1 :     let start = start_stream(&channel, &[]).await;
     254            1 :     let stream = stream_id(&start);
     255            1 :     assert_eq!(header(&start, "dashql-channel-id"), Some(channel.as_str()));
     256            1 :     assert_eq!(server.await.unwrap().data, "request");
     257              : 
     258            1 :     let response = read_stream(&channel, &stream, "5000", "5000", "1000000").await;
     259            1 :     assert_eq!(response.status(), 200, "{response:?}");
     260            1 :     assert_eq!(header(&response, "dashql-batch-event"), Some("StreamFinished"));
     261            1 :     assert_eq!(header(&response, "dashql-batch-messages"), Some("2"));
     262            1 :     assert_eq!(messages(response.body()), vec!["one", "two"]);
     263              : 
     264            1 :     assert_eq!(read_stream(&channel, &stream, "10", "10", "10").await.status(), 404);
     265            1 :     shutdown.send(()).unwrap();
     266            1 : }
     267              : 
     268              : #[tokio::test]
     269            1 : async fn flushes_a_batch_after_the_size_limit_and_after_the_timeout() {
     270            1 :     let (mock, _setup_unary, mut setup_streaming) = GrpcServiceMock::new();
     271            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     272            1 :     let channel = create_channel(address).await;
     273            1 :     let stream = {
     274            1 :         let server = tokio::spawn(async move { setup_streaming.recv().await.unwrap() });
     275            1 :         let stream = stream_id(&start_stream(&channel, &[]).await);
     276            1 :         let (_, sender) = server.await.unwrap();
     277              : 
     278            1 :         sender.send(message("a long enough message")).await.unwrap();
     279            1 :         let response = read_stream(&channel, &stream, "5000", "5000", "4").await;
     280            1 :         assert_eq!(header(&response, "dashql-batch-event"), Some("FlushAfterBytes"));
     281            1 :         assert_eq!(messages(response.body()), vec!["a long enough message"]);
     282              : 
     283            1 :         sender.send(message("partial")).await.unwrap();
     284            1 :         let response = read_stream(&channel, &stream, "5000", "100", "1000000").await;
     285            1 :         assert_eq!(header(&response, "dashql-batch-event"), Some("FlushAfterTimeout"));
     286            1 :         assert_eq!(messages(response.body()), vec!["partial"]);
     287              : 
     288              :         // Nothing arrives within the read timeout
     289            1 :         let response = read_stream(&channel, &stream, "50", "50", "1000").await;
     290            1 :         assert_eq!(error(&response)["message"], "reading from gRPC stream timed out");
     291            1 :         drop(sender);
     292            1 :         stream
     293              :     };
     294            1 :     let response = read_stream(&channel, &stream, "5000", "5000", "1000").await;
     295            1 :     assert_eq!(header(&response, "dashql-batch-event"), Some("StreamFinished"));
     296            1 :     shutdown.send(()).unwrap();
     297            1 : }
     298              : 
     299              : #[tokio::test]
     300            1 : async fn forwards_the_rich_error_of_a_failing_stream() {
     301            1 :     let (mock, _setup_unary, mut setup_streaming) = GrpcServiceMock::new();
     302            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     303            1 :     let channel = create_channel(address).await;
     304            1 :     let server = tokio::spawn(async move { setup_streaming.recv().await.unwrap() });
     305            1 :     let stream = stream_id(&start_stream(&channel, &[]).await);
     306            1 :     let (_, sender) = server.await.unwrap();
     307            1 :     sender.send(message("held back")).await.unwrap();
     308            1 :     let status = tonic::Status::with_details(tonic::Code::Internal, "failed\nmidway", vec![9].into());
     309            1 :     sender.send(Err(status)).await.unwrap();
     310              : 
     311            1 :     let response = read_stream(&channel, &stream, "5000", "5000", "1000000").await;
     312            1 :     assert_eq!(response.status(), 400);
     313            1 :     assert_eq!(header(&response, "dashql-grpc-status"), Some("13"));
     314            1 :     assert_eq!(header(&response, "dashql-grpc-status-details-bin"), Some("CQ=="));
     315            1 :     assert_eq!(error(&response)["message"], "reading from gRPC stream failed");
     316            1 :     assert_eq!(read_stream(&channel, &stream, "10", "10", "10").await.status(), 404);
     317            1 :     shutdown.send(()).unwrap();
     318            1 : }
     319              : 
     320              : #[tokio::test]
     321            1 : async fn requires_the_limits_of_a_read() {
     322            1 :     let (mock, _setup_unary, _setup_streaming) = GrpcServiceMock::new();
     323            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     324            1 :     let channel = create_channel(address).await;
     325            1 :     let path = format!("/grpc/channel/{channel}/stream/0");
     326            1 :     assert_eq!(error(&route_proxy_request(request("GET", &path, &[], &[])).await)["message"], "header is missing");
     327            1 :     let headers = [("dashql-read-timeout", "soon"), ("dashql-batch-timeout", "1"), ("dashql-batch-bytes", "1")];
     328            1 :     assert_eq!(error(&route_proxy_request(request("GET", &path, &headers, &[])).await)["message"], "header is not an unsigned integer");
     329            1 :     shutdown.send(()).unwrap();
     330            1 : }
     331              : 
     332              : #[tokio::test]
     333            1 : async fn deleting_a_stream_cancels_a_query_that_is_silent() {
     334            1 :     let (mock, _setup_unary, mut setup_streaming) = GrpcServiceMock::new();
     335            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     336            1 :     let channel = create_channel(address).await;
     337            1 :     let server = tokio::spawn(async move { setup_streaming.recv().await.unwrap() });
     338            1 :     let stream = stream_id(&start_stream(&channel, &[]).await);
     339            1 :     let (_, sender) = server.await.unwrap();
     340              : 
     341            1 :     let response = route_proxy_request(request("DELETE", &format!("/grpc/channel/{channel}/stream/{stream}"), &[], &[])).await;
     342            1 :     assert_eq!(response.status(), 200);
     343            1 :     assert_eq!(read_stream(&channel, &stream, "10", "10", "10").await.status(), 404);
     344            1 :     tokio::time::timeout(GUARD, sender.closed()).await.expect("the server was not told to stop");
     345            1 :     shutdown.send(()).unwrap();
     346            1 : }
     347              : 
     348              : #[tokio::test]
     349            1 : async fn deleting_a_stream_cancels_a_query_with_a_full_queue() {
     350            1 :     let (mock, _setup_unary, mut setup_streaming) = GrpcServiceMock::new();
     351            1 :     let (address, shutdown) = spawn_grpc_test_service_mock(mock).await;
     352            1 :     let channel = create_channel(address).await;
     353            1 :     let server = tokio::spawn(async move { setup_streaming.recv().await.unwrap() });
     354            1 :     let stream = stream_id(&start_stream(&channel, &[]).await);
     355            1 :     let (_, sender) = server.await.unwrap();
     356              : 
     357              :     // More messages than the queue holds, nobody reads them
     358           15 :     for i in 0..15 {
     359           15 :         sender.send(message(&format!("message {i}"))).await.unwrap();
     360              :     }
     361            1 :     tokio::time::sleep(Duration::from_millis(50)).await;
     362            1 :     route_proxy_request(request("DELETE", &format!("/grpc/channel/{channel}/stream/{stream}"), &[], &[])).await;
     363            1 :     tokio::time::timeout(GUARD, sender.closed()).await.expect("the server was not told to stop");
     364            1 :     shutdown.send(()).unwrap();
     365            1 : }
        

Generated by: LCOV version 2.0-1