//! Integration test for the one-port router composition //! (architecture/web-client.md): static assets, the gRPC-web route, and //! the auth layer must coexist. Driven through `tower::oneshot` so no //! socket, no provider backend, and no Tidal device login are needed. //! //! Only meaningful with the `web-ui` feature (the static fallback and //! gRPC-web layer live behind it); a no-web build has nothing to route. #![cfg(feature = "web-ui")] use std::sync::Arc; use base64::Engine; use crabidy_server::auth::Authenticator; use crabidy_server::rpc::RpcService; use crabidy_server::settings::AuthSettings; use http::{header, Method, Request, StatusCode}; use tower::ServiceExt; /// A service wired to dead channels: enough to build the router and /// exercise routing and the pre-handler auth layer (the two things /// under test); no RPC that reaches a handler is sent. fn service() -> RpcService { let (update_tx, _) = tokio::sync::broadcast::channel(4); let (playback_tx, _playback_rx) = flume::unbounded(); let (provider_tx, _provider_rx) = flume::unbounded(); RpcService::new(update_tx, playback_tx, provider_tx, false) } fn hash(password: &str) -> String { use argon2::password_hash::{rand_core::OsRng, SaltString}; use argon2::{Argon2, PasswordHasher}; let params = argon2::Params::new(8, 1, 1, None).expect("params"); let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); argon2 .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng)) .expect("hash") .to_string() } #[tokio::test] async fn the_root_serves_the_embedded_app_shell() { let router = crabidy_server::build_router( service(), Arc::new(Authenticator::new(&AuthSettings::default())), ); let response = router .oneshot( Request::builder() .uri("/") .body(axum::body::Body::empty()) .unwrap(), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let content_type = response.headers()[header::CONTENT_TYPE].to_str().unwrap(); assert!(content_type.starts_with("text/html"), "{content_type}"); let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); assert!( String::from_utf8_lossy(&bytes).contains("crabidy"), "app shell (or placeholder) served" ); } #[tokio::test] async fn unknown_get_paths_fall_back_to_the_shell() { let router = crabidy_server::build_router( service(), Arc::new(Authenticator::new(&AuthSettings::default())), ); let response = router .oneshot( Request::builder() .uri("/library/deep/link") .body(axum::body::Body::empty()) .unwrap(), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); assert!(response.headers()[header::CONTENT_TYPE] .to_str() .unwrap() .starts_with("text/html")); } #[tokio::test] async fn grpc_web_calls_route_through_the_auth_layer() { // A fully-locked server (every role guarded, so anonymous callers get // nothing): an unauthenticated gRPC-web POST must be rejected by the // layer (gRPC status UNAUTHENTICATED = 16) *before* reaching a handler // — so the dead channels never matter. let auth = Authenticator::new(&AuthSettings { owner: Some(hash("pw")), queue_owner: Some(hash("qo")), queue_appender: Some(hash("qa")), }); let router = crabidy_server::build_router(service(), Arc::new(auth)); let response = router .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/crabidy.v1.CrabidyService/Init") .header(header::CONTENT_TYPE, "application/grpc-web+proto") .header("x-grpc-web", "1") .body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0])) .unwrap(), ) .await .expect("response"); // gRPC-web reports the status in a header (trailers-only), HTTP 200. let grpc_status = response .headers() .get("grpc-status") .and_then(|v| v.to_str().ok()); assert_eq!(grpc_status, Some("16"), "unauthenticated gRPC-web call"); // With a valid owner credential the layer passes the request // through to the service; owner is allowed for Init, so it is not // rejected. The stub handler then fails on its dead channels — any // outcome other than the auth codes proves the request cleared the // layer and reached the handler. let authed = base64::engine::general_purpose::STANDARD.encode("owner:pw"); let response = router .oneshot( Request::builder() .method(Method::POST) .uri("/crabidy.v1.CrabidyService/Init") .header(header::CONTENT_TYPE, "application/grpc-web+proto") .header("x-grpc-web", "1") .header(header::AUTHORIZATION, format!("Basic {authed}")) .body(axum::body::Body::from(vec![0u8, 0, 0, 0, 0])) .unwrap(), ) .await .expect("response"); let grpc_status = response .headers() .get("grpc-status") .and_then(|v| v.to_str().ok()); assert_ne!( grpc_status, Some("16"), "authorized call must not be UNAUTHENTICATED" ); assert_ne!( grpc_status, Some("7"), "owner must not be PERMISSION_DENIED for Init" ); } /// The TUI speaks native gRPC (HTTP/2 prior knowledge, no TLS). Moving /// the server from `tonic::transport::Server` to `axum::serve` must not /// break that: bind the real router to a socket and call it with a /// native tonic client. A gRPC *status* back (rather than a transport /// error) proves h2c negotiated and the request reached the service. #[tokio::test] async fn native_grpc_still_works_through_the_axum_server() { use crabidy_core::proto::crabidy::crabidy_service_client::CrabidyServiceClient; use crabidy_core::proto::crabidy::GetLibraryNodeRequest; let router = crabidy_server::build_router( service(), Arc::new(Authenticator::new(&AuthSettings::default())), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap(); }); let channel = tonic::transport::Endpoint::from_shared(format!("http://{addr}")) .unwrap() .connect() .await .expect("native h2c connect"); let mut client = CrabidyServiceClient::new(channel); // Dead channels make the handler fail fast; we only assert the // round trip produced a gRPC status, i.e. the transport worked. let result = tokio::time::timeout( std::time::Duration::from_secs(2), client.get_library_node(GetLibraryNodeRequest { path: "/".to_string(), }), ) .await .expect("no transport hang"); assert!( result.is_err(), "the stub handler errors on dead channels — but the call round-tripped" ); server.abort(); }