crabidy/crabidy-server/tests/web_server.rs

197 lines
7.1 KiB
Rust

//! 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)
}
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 credentialed server: 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: None,
queue_appender: None,
});
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();
}