Skip to content

Commit c5c7581

Browse files
benthecarmanclaude
andcommitted
Add tests for request validation, responses, and auth
Test validate_grpc_request (method and content-type checks), grpc_error_response/grpc_response structure, GrpcBody poll sequences (Unary and Empty), all ldk_error_to_grpc_status variants, and auth edge cases (timestamp boundaries, future timestamps, malformed HMACs, empty API key). Also make validate_grpc_request generic over body type since it only inspects headers — this enabled testing without needing to construct hyper::body::Incoming. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e139e23 commit c5c7581

2 files changed

Lines changed: 242 additions & 2 deletions

File tree

ldk-server/src/grpc.rs

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::pin::Pin;
1616
use std::task::{Context, Poll};
1717

1818
use bytes::{BufMut, Bytes, BytesMut};
19-
use hyper::body::{Frame, Incoming};
19+
use hyper::body::Frame;
2020
use hyper::header::HeaderValue;
2121
use hyper::http::HeaderMap;
2222
use hyper::{Request, Response};
@@ -255,7 +255,7 @@ pub(crate) fn grpc_response(body: GrpcBody) -> Response<GrpcBody> {
255255
}
256256

257257
/// Validate that the request looks like a gRPC call.
258-
pub(crate) fn validate_grpc_request(req: &Request<Incoming>) -> Result<(), GrpcStatus> {
258+
pub(crate) fn validate_grpc_request<B>(req: &Request<B>) -> Result<(), GrpcStatus> {
259259
if req.method() != hyper::Method::POST {
260260
return Err(GrpcStatus::new(GRPC_STATUS_UNIMPLEMENTED, "gRPC requires POST method"));
261261
}
@@ -445,4 +445,174 @@ mod tests {
445445
assert_eq!(parse_grpc_timeout("S"), None);
446446
assert_eq!(parse_grpc_timeout("5x"), None);
447447
}
448+
449+
#[test]
450+
fn test_validate_grpc_request_valid() {
451+
let req = Request::builder()
452+
.method("POST")
453+
.header("content-type", "application/grpc")
454+
.body(())
455+
.unwrap();
456+
assert!(validate_grpc_request(&req).is_ok());
457+
458+
let req = Request::builder()
459+
.method("POST")
460+
.header("content-type", "application/grpc+proto")
461+
.body(())
462+
.unwrap();
463+
assert!(validate_grpc_request(&req).is_ok());
464+
}
465+
466+
#[test]
467+
fn test_validate_grpc_request_wrong_method() {
468+
let req = Request::builder()
469+
.method("GET")
470+
.header("content-type", "application/grpc")
471+
.body(())
472+
.unwrap();
473+
let err = validate_grpc_request(&req).unwrap_err();
474+
assert_eq!(err.code, GRPC_STATUS_UNIMPLEMENTED);
475+
}
476+
477+
#[test]
478+
fn test_validate_grpc_request_wrong_content_type() {
479+
let cases =
480+
["application/json", "application/grpc+json", "application/grpcfoo", "text/plain", ""];
481+
for ct in &cases {
482+
let req =
483+
Request::builder().method("POST").header("content-type", *ct).body(()).unwrap();
484+
let err = validate_grpc_request(&req).unwrap_err();
485+
assert_eq!(err.code, GRPC_STATUS_INVALID_ARGUMENT, "should reject content-type {ct:?}");
486+
}
487+
}
488+
489+
#[test]
490+
fn test_validate_grpc_request_missing_content_type() {
491+
let req = Request::builder().method("POST").body(()).unwrap();
492+
let err = validate_grpc_request(&req).unwrap_err();
493+
assert_eq!(err.code, GRPC_STATUS_INVALID_ARGUMENT);
494+
}
495+
496+
#[test]
497+
fn test_ldk_error_to_grpc_status_all_variants() {
498+
let cases = [
499+
(LdkServerErrorCode::InvalidRequestError, GRPC_STATUS_INVALID_ARGUMENT),
500+
(LdkServerErrorCode::AuthError, GRPC_STATUS_UNAUTHENTICATED),
501+
(LdkServerErrorCode::LightningError, GRPC_STATUS_FAILED_PRECONDITION),
502+
(LdkServerErrorCode::InternalServerError, GRPC_STATUS_INTERNAL),
503+
];
504+
for (error_code, expected_grpc_code) in cases {
505+
let e = LdkServerError::new(error_code, "test message");
506+
let s = ldk_error_to_grpc_status(e);
507+
assert_eq!(s.code, expected_grpc_code);
508+
assert_eq!(s.message, "test message");
509+
}
510+
}
511+
512+
#[test]
513+
fn test_grpc_error_response_structure() {
514+
let status = GrpcStatus::new(GRPC_STATUS_INTERNAL, "something broke");
515+
let resp = grpc_error_response(status);
516+
517+
assert_eq!(resp.status(), 200);
518+
assert_eq!(resp.headers().get("content-type").unwrap(), GRPC_CONTENT_TYPE);
519+
assert_eq!(
520+
resp.headers().get(GRPC_ACCEPT_ENCODING_HEADER).unwrap(),
521+
GRPC_ENCODING_IDENTITY
522+
);
523+
assert_eq!(
524+
resp.headers().get(GRPC_STATUS_HEADER).unwrap(),
525+
&GRPC_STATUS_INTERNAL.to_string()
526+
);
527+
assert_eq!(resp.headers().get(GRPC_MESSAGE_HEADER).unwrap(), "something broke");
528+
}
529+
530+
#[test]
531+
fn test_grpc_error_response_empty_message_omits_grpc_message() {
532+
let status = GrpcStatus::new(GRPC_STATUS_UNAUTHENTICATED, "");
533+
let resp = grpc_error_response(status);
534+
535+
assert_eq!(resp.headers().get(GRPC_STATUS_HEADER).unwrap(), "16");
536+
assert!(resp.headers().get(GRPC_MESSAGE_HEADER).is_none());
537+
}
538+
539+
#[test]
540+
fn test_grpc_response_headers() {
541+
let body = GrpcBody::Unary { data: Some(Bytes::new()), trailers_sent: false };
542+
let resp = grpc_response(body);
543+
544+
assert_eq!(resp.status(), 200);
545+
assert_eq!(resp.headers().get("content-type").unwrap(), GRPC_CONTENT_TYPE);
546+
assert_eq!(
547+
resp.headers().get(GRPC_ACCEPT_ENCODING_HEADER).unwrap(),
548+
GRPC_ENCODING_IDENTITY
549+
);
550+
}
551+
552+
#[test]
553+
fn test_grpc_body_unary_poll_sequence() {
554+
use hyper::body::Body;
555+
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
556+
557+
fn noop_waker() -> Waker {
558+
fn no_op(_: *const ()) {}
559+
fn clone(p: *const ()) -> RawWaker {
560+
RawWaker::new(p, &VTABLE)
561+
}
562+
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
563+
unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
564+
}
565+
566+
let payload = encode_grpc_frame(b"test");
567+
let mut body = GrpcBody::Unary { data: Some(payload.clone()), trailers_sent: false };
568+
569+
let waker = noop_waker();
570+
let mut cx = Context::from_waker(&waker);
571+
572+
// First poll: data frame
573+
let frame = Pin::new(&mut body).poll_frame(&mut cx);
574+
match frame {
575+
Poll::Ready(Some(Ok(ref f))) => assert!(f.is_data()),
576+
ref other => panic!("expected data frame, got {other:?}"),
577+
}
578+
579+
// Second poll: trailers
580+
let frame = Pin::new(&mut body).poll_frame(&mut cx);
581+
match frame {
582+
Poll::Ready(Some(Ok(f))) => {
583+
let trailers = f.into_trailers().expect("expected trailers");
584+
assert_eq!(trailers.get(GRPC_STATUS_HEADER).unwrap(), "0");
585+
},
586+
ref other => panic!("expected trailers frame, got {other:?}"),
587+
}
588+
589+
// Third poll: end of stream
590+
let frame: Poll<Option<Result<hyper::body::Frame<Bytes>, hyper::Error>>> =
591+
Pin::new(&mut body).poll_frame(&mut cx);
592+
assert!(matches!(frame, Poll::Ready(None)));
593+
}
594+
595+
#[test]
596+
fn test_grpc_body_empty_poll() {
597+
use hyper::body::Body;
598+
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
599+
600+
fn noop_waker() -> Waker {
601+
fn no_op(_: *const ()) {}
602+
fn clone(p: *const ()) -> RawWaker {
603+
RawWaker::new(p, &VTABLE)
604+
}
605+
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
606+
unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
607+
}
608+
609+
let mut body = GrpcBody::Empty;
610+
let waker = noop_waker();
611+
let mut cx = Context::from_waker(&waker);
612+
613+
// Empty body returns None immediately
614+
let frame: Poll<Option<Result<hyper::body::Frame<Bytes>, hyper::Error>>> =
615+
Pin::new(&mut body).poll_frame(&mut cx);
616+
assert!(matches!(frame, Poll::Ready(None)));
617+
}
448618
}

ldk-server/src/service.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,4 +539,74 @@ mod tests {
539539
assert!(result.is_err());
540540
assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError);
541541
}
542+
543+
#[test]
544+
fn test_validate_auth_timestamp_at_tolerance_boundary() {
545+
let api_key = "test_api_key";
546+
// Exactly at the boundary (60 seconds ago) should still be accepted
547+
let now =
548+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
549+
let timestamp = now - AUTH_TIMESTAMP_TOLERANCE_SECS;
550+
let hmac = compute_hmac(api_key, timestamp);
551+
let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}")));
552+
assert!(validate_auth(&req, api_key).is_ok());
553+
554+
// One second past the boundary should be rejected
555+
let timestamp = now - AUTH_TIMESTAMP_TOLERANCE_SECS - 1;
556+
let hmac = compute_hmac(api_key, timestamp);
557+
let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}")));
558+
assert!(validate_auth(&req, api_key).is_err());
559+
}
560+
561+
#[test]
562+
fn test_validate_auth_future_timestamp() {
563+
let api_key = "test_api_key";
564+
// Slightly in the future should be accepted (clock skew)
565+
let now =
566+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
567+
let timestamp = now + 30;
568+
let hmac = compute_hmac(api_key, timestamp);
569+
let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}")));
570+
assert!(validate_auth(&req, api_key).is_ok());
571+
572+
// Far future should be rejected
573+
let timestamp = now + AUTH_TIMESTAMP_TOLERANCE_SECS + 1;
574+
let hmac = compute_hmac(api_key, timestamp);
575+
let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}")));
576+
assert!(validate_auth(&req, api_key).is_err());
577+
}
578+
579+
#[test]
580+
fn test_validate_auth_malformed_hmac() {
581+
let now =
582+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
583+
// Truncated HMAC
584+
let req = create_test_request(Some(format!("HMAC {now}:deadbeef")));
585+
let result = validate_auth(&req, "test_key");
586+
assert!(result.is_err());
587+
assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError);
588+
589+
// Non-hex HMAC
590+
let req = create_test_request(Some(format!("HMAC {now}:not-hex-at-all!")));
591+
let result = validate_auth(&req, "test_key");
592+
assert!(result.is_err());
593+
assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError);
594+
595+
// Empty HMAC
596+
let req = create_test_request(Some(format!("HMAC {now}:")));
597+
let result = validate_auth(&req, "test_key");
598+
assert!(result.is_err());
599+
assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError);
600+
}
601+
602+
#[test]
603+
fn test_validate_auth_empty_api_key() {
604+
let api_key = "";
605+
let timestamp =
606+
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
607+
let hmac = compute_hmac(api_key, timestamp);
608+
let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}")));
609+
// Empty key should still work (HMAC is well-defined for empty keys)
610+
assert!(validate_auth(&req, api_key).is_ok());
611+
}
542612
}

0 commit comments

Comments
 (0)