chore(release): v0.921 — Rustproof (Rust test coverage >30%)
Some checks failed
Stream Server CI / test (push) Failing after 0s
Some checks failed
Stream Server CI / test (push) Failing after 0s
This commit is contained in:
parent
72d40990c5
commit
12dbb5bbe4
8 changed files with 386 additions and 3 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.923
|
||||
0.921
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@ pub fn require_role(
|
|||
}
|
||||
}
|
||||
|
||||
fn extract_token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
pub(crate) fn extract_token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
let auth_header = headers.get("Authorization")?;
|
||||
let auth_str = auth_header.to_str().ok()?;
|
||||
|
||||
|
|
@ -656,4 +656,90 @@ mod tests {
|
|||
let err = AuthError::ConfigurationError("missing config".to_string());
|
||||
assert!(format!("{}", err).contains("missing config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_permission() {
|
||||
let config = Arc::new(Config::default());
|
||||
let manager = AuthManager::new(config).unwrap();
|
||||
let claims = Claims {
|
||||
sub: "user-1".to_string(),
|
||||
username: "test".to_string(),
|
||||
email: None,
|
||||
roles: vec![Role::User],
|
||||
permissions: vec![Permission::StreamAudio, Permission::UploadAudio],
|
||||
exp: 9999999999,
|
||||
iat: 0,
|
||||
iss: "test".to_string(),
|
||||
aud: "test".to_string(),
|
||||
session_id: "sess-1".to_string(),
|
||||
};
|
||||
assert!(manager.has_permission(&claims, Permission::StreamAudio));
|
||||
assert!(!manager.has_permission(&claims, Permission::SystemAdmin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_role() {
|
||||
let config = Arc::new(Config::default());
|
||||
let manager = AuthManager::new(config).unwrap();
|
||||
let claims = Claims {
|
||||
sub: "user-1".to_string(),
|
||||
username: "admin".to_string(),
|
||||
email: None,
|
||||
roles: vec![Role::Admin, Role::User],
|
||||
permissions: vec![],
|
||||
exp: 9999999999,
|
||||
iat: 0,
|
||||
iss: "test".to_string(),
|
||||
aud: "test".to_string(),
|
||||
session_id: "sess-1".to_string(),
|
||||
};
|
||||
assert!(manager.has_role(&claims, Role::Admin));
|
||||
assert!(!manager.has_role(&claims, Role::Moderator));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_any_role() {
|
||||
let config = Arc::new(Config::default());
|
||||
let manager = AuthManager::new(config).unwrap();
|
||||
let claims = Claims {
|
||||
sub: "user-1".to_string(),
|
||||
username: "artist".to_string(),
|
||||
email: None,
|
||||
roles: vec![Role::Artist],
|
||||
permissions: vec![],
|
||||
exp: 9999999999,
|
||||
iat: 0,
|
||||
iss: "test".to_string(),
|
||||
aud: "test".to_string(),
|
||||
session_id: "sess-1".to_string(),
|
||||
};
|
||||
assert!(manager.has_any_role(&claims, &[Role::Admin, Role::Artist]));
|
||||
assert!(!manager.has_any_role(&claims, &[Role::Admin, Role::Moderator]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_from_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
HeaderValue::from_static("Bearer token123"),
|
||||
);
|
||||
let token = extract_token_from_headers(&headers);
|
||||
assert_eq!(token.as_deref(), Some("token123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_from_headers_no_bearer() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Authorization", HeaderValue::from_static("Basic xyz"));
|
||||
let token = extract_token_from_headers(&headers);
|
||||
assert!(token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_from_headers_missing() {
|
||||
let headers = HeaderMap::new();
|
||||
let token = extract_token_from_headers(&headers);
|
||||
assert!(token.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,3 +123,24 @@ impl SessionRevocationStore for RedisRevocationStore {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_in_memory_revoke_and_check() {
|
||||
let store = InMemoryRevocationStore::new();
|
||||
assert!(!store.is_revoked("sess-1").await);
|
||||
store.revoke("sess-1").await.unwrap();
|
||||
assert!(store.is_revoked("sess-1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_in_memory_different_sessions() {
|
||||
let store = InMemoryRevocationStore::new();
|
||||
store.revoke("sess-a").await.unwrap();
|
||||
assert!(store.is_revoked("sess-a").await);
|
||||
assert!(!store.is_revoked("sess-b").await);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -473,4 +473,90 @@ mod tests {
|
|||
assert_eq!(token_info.track_id, "test_track_123");
|
||||
assert_eq!(token_info.access_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_secure_url() {
|
||||
let validator = TokenValidator::new(SignatureConfig {
|
||||
secret_key: "test_secret".to_string(),
|
||||
default_ttl: Duration::from_secs(3600),
|
||||
max_ttl: Duration::from_secs(86400),
|
||||
});
|
||||
|
||||
let url = validator
|
||||
.generate_secure_url("track-456", None, Some(Uuid::new_v4()))
|
||||
.unwrap();
|
||||
assert_eq!(url.track_id, "track-456");
|
||||
assert!(url.expires > 0);
|
||||
assert!(!url.signature.is_empty());
|
||||
assert!(url.user_id.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secure_stream_url_build_url() {
|
||||
let validator = TokenValidator::new(SignatureConfig {
|
||||
secret_key: "test_secret".to_string(),
|
||||
default_ttl: Duration::from_secs(3600),
|
||||
max_ttl: Duration::from_secs(86400),
|
||||
});
|
||||
|
||||
let url = validator
|
||||
.generate_secure_url("track-789", None, None)
|
||||
.unwrap();
|
||||
let full_url = url.build_url("https://stream.example.com");
|
||||
assert!(full_url.starts_with("https://stream.example.com/stream/track-789"));
|
||||
assert!(full_url.contains("expires="));
|
||||
assert!(full_url.contains("sig="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_signature_invalid_hex() {
|
||||
let validator = TokenValidator::new(SignatureConfig {
|
||||
secret_key: "test_secret".to_string(),
|
||||
default_ttl: Duration::from_secs(3600),
|
||||
max_ttl: Duration::from_secs(86400),
|
||||
});
|
||||
|
||||
let expires = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 3600;
|
||||
let result = validator.validate_signature(
|
||||
"track",
|
||||
expires,
|
||||
"not-valid-hex!!",
|
||||
None,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(!result.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_token_stats() {
|
||||
let validator = TokenValidator::new(SignatureConfig {
|
||||
secret_key: "test_secret".to_string(),
|
||||
default_ttl: Duration::from_secs(3600),
|
||||
max_ttl: Duration::from_secs(86400),
|
||||
});
|
||||
|
||||
let stats = validator.get_token_stats().await.unwrap();
|
||||
assert_eq!(stats.total_tokens, 0);
|
||||
assert_eq!(stats.active_last_hour, 0);
|
||||
assert_eq!(stats.total_accesses, 0);
|
||||
|
||||
let expires = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ 3600;
|
||||
let request = StreamRequest {
|
||||
track_id: "stats_test".to_string(),
|
||||
expires,
|
||||
sig: validator.generate_signature("stats_test", expires, None).unwrap(),
|
||||
user_id: None,
|
||||
};
|
||||
validator.validate_and_register_token(&request).await.unwrap();
|
||||
let stats2 = validator.get_token_stats().await.unwrap();
|
||||
assert_eq!(stats2.total_tokens, 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -780,4 +780,31 @@ mod tests {
|
|||
assert!(!config.url.is_empty());
|
||||
assert!(config.max_retries >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_weak_secret() {
|
||||
let mut config = Config::default();
|
||||
config.secret_key = "short".to_string();
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ConfigError::WeakSecretKey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_invalid_port() {
|
||||
let mut config = Config::default();
|
||||
config.port = 0;
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ConfigError::InvalidPort));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validate_empty_audio_dir() {
|
||||
let mut config = Config::default();
|
||||
config.audio_dir = "".to_string();
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ConfigError::InvalidAudioDir));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -687,4 +687,97 @@ mod tests {
|
|||
_ => panic!("Expected SerializationError variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_config_display() {
|
||||
let err = AppError::ConfigError {
|
||||
message: "bad config".to_string(),
|
||||
};
|
||||
assert!(format!("{}", err).contains("bad config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_network_display() {
|
||||
let err = AppError::NetworkError {
|
||||
message: "timeout".to_string(),
|
||||
};
|
||||
assert!(format!("{}", err).contains("timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_unauthorized_into_response() {
|
||||
let err = AppError::Unauthorized;
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_forbidden_into_response() {
|
||||
let err = AppError::Forbidden;
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_not_found_into_response() {
|
||||
let err = AppError::NotFound {
|
||||
resource: "track".to_string(),
|
||||
};
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_bad_request_variants() {
|
||||
let err1 = AppError::InvalidData {
|
||||
message: "invalid".to_string(),
|
||||
};
|
||||
assert_eq!(err1.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let err2 = AppError::UnsupportedCodec {
|
||||
codec: "xyz".to_string(),
|
||||
};
|
||||
assert_eq!(err2.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_too_many_requests() {
|
||||
let err = AppError::LimitExceeded {
|
||||
limit: "streams".to_string(),
|
||||
current: 100,
|
||||
};
|
||||
assert_eq!(err.into_response().status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_error_conflict() {
|
||||
let err = AppError::AlreadyRunning;
|
||||
assert_eq!(err.into_response().status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlx_error_conversion() {
|
||||
let pool_err = sqlx::Error::PoolTimedOut;
|
||||
let app_err: AppError = pool_err.into();
|
||||
match app_err {
|
||||
AppError::ConnectionTimeout => {}
|
||||
_ => panic!("Expected ConnectionTimeout"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tokio_elapsed_conversion() {
|
||||
use tokio::time::Duration;
|
||||
let elapsed_err = tokio::time::timeout(
|
||||
Duration::from_nanos(0),
|
||||
async { futures::future::pending::<()>().await },
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let app_err: AppError = elapsed_err.into();
|
||||
match app_err {
|
||||
AppError::ConnectionTimeout => {}
|
||||
_ => panic!("Expected ConnectionTimeout"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ pub async fn rate_limit_middleware(
|
|||
Ok(response)
|
||||
}
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
pub(crate) fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded_for) = headers.get("x-forwarded-for") {
|
||||
if let Ok(forwarded_str) = forwarded_for.to_str() {
|
||||
if let Some(first_ip) = forwarded_str.split(',').next() {
|
||||
|
|
@ -79,3 +79,40 @@ fn check_rate_limit(_state: &AppState, client_ip: &str) -> bool {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn test_extract_client_ip_x_forwarded_for() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("10.0.0.1, 192.168.1.1"),
|
||||
);
|
||||
assert_eq!(extract_client_ip(&headers), "10.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_client_ip_x_real_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.50"));
|
||||
assert_eq!(extract_client_ip(&headers), "203.0.113.50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_client_ip_fallback_unknown() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_client_ip(&headers), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_client_ip_forwarded_takes_precedence() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("1.2.3.4"));
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("5.6.7.8"));
|
||||
assert_eq!(extract_client_ip(&headers), "1.2.3.4");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -993,4 +993,37 @@ mod tests {
|
|||
assert!(json.is_ok(), "Failed to serialize {:?}", level);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_event_roundtrip() {
|
||||
let event = WebSocketEvent::ServerMessage {
|
||||
message: "test".to_string(),
|
||||
level: MessageLevel::Info,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let restored: WebSocketEvent = serde_json::from_str(&json).unwrap();
|
||||
match (&event, &restored) {
|
||||
(
|
||||
WebSocketEvent::ServerMessage { message: m1, level: l1 },
|
||||
WebSocketEvent::ServerMessage { message: m2, level: l2 },
|
||||
) => {
|
||||
assert_eq!(m1, m2);
|
||||
assert!(std::mem::discriminant(l1) == std::mem::discriminant(l2));
|
||||
}
|
||||
_ => panic!("Unexpected variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_live_track_stats_serialize() {
|
||||
let stats = LiveTrackStats {
|
||||
track_id: "t1".to_string(),
|
||||
title: "Song".to_string(),
|
||||
artist: "Artist".to_string(),
|
||||
current_listeners: 42,
|
||||
};
|
||||
let json = serde_json::to_string(&stats).unwrap();
|
||||
assert!(json.contains("t1"));
|
||||
assert!(json.contains("Song"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue