Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions crates/mcpmux-core/src/event_bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,35 @@ mod tests {
let count = sender.emit(DomainEvent::GatewayStopped);
assert_eq!(count, 0);
}

#[test]
fn try_recv_none_when_empty() {
let bus = EventBus::new();
let mut receiver = bus.subscribe();

// No events emitted yet
assert!(receiver.try_recv().is_none());
}

#[test]
fn try_recv_returns_event() {
let bus = EventBus::new();
let sender = bus.sender();
let mut receiver = bus.subscribe();

sender.emit(DomainEvent::GatewayStopped);

let event = receiver.try_recv();
assert!(event.is_some());
assert_eq!(event.unwrap().type_name(), "gateway_stopped");
}

#[test]
fn emit_or_warn_no_receivers_does_not_panic() {
let bus = EventBus::new();
let sender = bus.sender();

// Should not panic even with no receivers
sender.emit_or_warn(DomainEvent::GatewayStopped);
}
}
64 changes: 64 additions & 0 deletions crates/mcpmux-gateway/src/pool/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,3 +738,67 @@ impl RoutingService {
false
}
}

#[cfg(test)]
mod tests {
use super::*;

// is_auth_error tests

#[test]
fn is_auth_error_401() {
assert!(RoutingService::is_auth_error("got 401 from server"));
}

#[test]
fn is_auth_error_unauthorized() {
assert!(RoutingService::is_auth_error("unauthorized access denied"));
}

#[test]
fn is_auth_error_invalid_token() {
assert!(RoutingService::is_auth_error(
"invalid_token: token revoked"
));
}

#[test]
fn is_auth_error_token_expired() {
assert!(RoutingService::is_auth_error("token expired at 12:00"));
}

#[test]
fn is_auth_error_access_token() {
assert!(RoutingService::is_auth_error("access token is invalid"));
}

#[test]
fn is_auth_error_unrelated() {
assert!(!RoutingService::is_auth_error("connection refused"));
}

#[test]
fn is_auth_error_empty() {
assert!(!RoutingService::is_auth_error(""));
}

// content_has_auth_error tests

#[test]
fn content_auth_error_in_text() {
let content = vec![serde_json::json!({"type": "text", "text": "Error: 401 Unauthorized"})];
assert!(RoutingService::content_has_auth_error(&content));
}

#[test]
fn content_no_text_field() {
let content = vec![serde_json::json!({"type": "image", "data": "base64..."})];
assert!(!RoutingService::content_has_auth_error(&content));
}

#[test]
fn content_empty_slice() {
let content: Vec<Value> = vec![];
assert!(!RoutingService::content_has_auth_error(&content));
}
}
133 changes: 133 additions & 0 deletions crates/mcpmux-gateway/src/server/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,136 @@ pub fn default_oauth_rate_limiter() -> RateLimiter {
),
])
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn allows_within_limit() {
let limiter = RateLimiter::new(vec![(
"/test".to_string(),
RateLimitConfig {
max_requests: 5,
window: Duration::from_secs(60),
},
)]);

for _ in 0..5 {
assert!(limiter.check("/test"), "Should allow requests within limit");
}
}

#[test]
fn blocks_at_limit() {
let limiter = RateLimiter::new(vec![(
"/test".to_string(),
RateLimitConfig {
max_requests: 3,
window: Duration::from_secs(60),
},
)]);

assert!(limiter.check("/test")); // 1
assert!(limiter.check("/test")); // 2
assert!(limiter.check("/test")); // 3
assert!(!limiter.check("/test"), "Should block at limit");
}

#[test]
fn no_matching_rule_allows() {
let limiter = RateLimiter::new(vec![(
"/oauth".to_string(),
RateLimitConfig {
max_requests: 1,
window: Duration::from_secs(60),
},
)]);

assert!(limiter.check("/health"), "Unmatched path should be allowed");
}

#[test]
fn prefix_matching() {
let limiter = RateLimiter::new(vec![(
"/oauth/token".to_string(),
RateLimitConfig {
max_requests: 1,
window: Duration::from_secs(60),
},
)]);

assert!(limiter.check("/oauth/token/extra")); // matches prefix, count=1
assert!(
!limiter.check("/oauth/token"),
"Second request to same prefix should be blocked"
);
}

#[test]
fn independent_buckets() {
let limiter = RateLimiter::new(vec![
(
"/oauth/authorize".to_string(),
RateLimitConfig {
max_requests: 1,
window: Duration::from_secs(60),
},
),
(
"/oauth/token".to_string(),
RateLimitConfig {
max_requests: 1,
window: Duration::from_secs(60),
},
),
]);

assert!(limiter.check("/oauth/authorize"));
assert!(
!limiter.check("/oauth/authorize"),
"authorize should be blocked"
);
// token should still be allowed (independent counter)
assert!(limiter.check("/oauth/token"));
}

#[test]
fn default_has_expected_rules() {
let limiter = default_oauth_rate_limiter();
// Verify it has rules for 5 OAuth paths by checking they are rate-limited
let paths = [
"/oauth/authorize",
"/authorize",
"/oauth/token",
"/oauth/register",
"/oauth/clients",
];
for path in &paths {
assert!(
limiter.check(path),
"First request to {} should be allowed",
path
);
}
}

#[test]
fn window_reset() {
let limiter = RateLimiter::new(vec![(
"/test".to_string(),
RateLimitConfig {
max_requests: 1,
window: Duration::from_millis(1), // 1ms window
},
)]);

assert!(limiter.check("/test")); // count=1
assert!(!limiter.check("/test")); // blocked

// Sleep past the window
std::thread::sleep(Duration::from_millis(10));

assert!(limiter.check("/test"), "Should allow after window reset");
}
}
Loading