diff --git a/VERSION b/VERSION index 27099d582..9bff09870 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.923 +0.921 diff --git a/veza-stream-server/src/auth/mod.rs b/veza-stream-server/src/auth/mod.rs index 2d84616b5..5285af501 100644 --- a/veza-stream-server/src/auth/mod.rs +++ b/veza-stream-server/src/auth/mod.rs @@ -429,7 +429,7 @@ pub fn require_role( } } -fn extract_token_from_headers(headers: &HeaderMap) -> Option { +pub(crate) fn extract_token_from_headers(headers: &HeaderMap) -> Option { 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()); + } } diff --git a/veza-stream-server/src/auth/revocation_store.rs b/veza-stream-server/src/auth/revocation_store.rs index c0c2b2426..60c9ad9f3 100644 --- a/veza-stream-server/src/auth/revocation_store.rs +++ b/veza-stream-server/src/auth/revocation_store.rs @@ -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); + } +} diff --git a/veza-stream-server/src/auth/token_validator.rs b/veza-stream-server/src/auth/token_validator.rs index fe1e4a146..53634ab5e 100644 --- a/veza-stream-server/src/auth/token_validator.rs +++ b/veza-stream-server/src/auth/token_validator.rs @@ -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); + } } diff --git a/veza-stream-server/src/config/mod.rs b/veza-stream-server/src/config/mod.rs index 96e51143e..8a44cd31a 100644 --- a/veza-stream-server/src/config/mod.rs +++ b/veza-stream-server/src/config/mod.rs @@ -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)); + } } diff --git a/veza-stream-server/src/error.rs b/veza-stream-server/src/error.rs index 71bfc2ccd..1c9de0230 100644 --- a/veza-stream-server/src/error.rs +++ b/veza-stream-server/src/error.rs @@ -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"), + } + } } diff --git a/veza-stream-server/src/middleware/rate_limit.rs b/veza-stream-server/src/middleware/rate_limit.rs index 219348d23..8ae6c564f 100644 --- a/veza-stream-server/src/middleware/rate_limit.rs +++ b/veza-stream-server/src/middleware/rate_limit.rs @@ -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"); + } +} diff --git a/veza-stream-server/src/streaming/websocket.rs b/veza-stream-server/src/streaming/websocket.rs index bf0784d46..e1fb3eb71 100644 --- a/veza-stream-server/src/streaming/websocket.rs +++ b/veza-stream-server/src/streaming/websocket.rs @@ -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")); + } }