198 lines
7.2 KiB
Rust
198 lines
7.2 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, 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();
|
|
}
|