diff --git a/nora-registry/src/activity_log.rs b/nora-registry/src/activity_log.rs index 9844011..3f3ce75 100644 --- a/nora-registry/src/activity_log.rs +++ b/nora-registry/src/activity_log.rs @@ -99,3 +99,139 @@ impl Default for ActivityLog { Self::new(50) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_action_type_display() { + assert_eq!(ActionType::Pull.to_string(), "PULL"); + assert_eq!(ActionType::Push.to_string(), "PUSH"); + assert_eq!(ActionType::CacheHit.to_string(), "CACHE"); + assert_eq!(ActionType::ProxyFetch.to_string(), "PROXY"); + } + + #[test] + fn test_action_type_equality() { + assert_eq!(ActionType::Pull, ActionType::Pull); + assert_ne!(ActionType::Pull, ActionType::Push); + } + + #[test] + fn test_activity_entry_new() { + let entry = ActivityEntry::new( + ActionType::Pull, + "nginx:latest".to_string(), + "docker", + "LOCAL", + ); + assert_eq!(entry.action, ActionType::Pull); + assert_eq!(entry.artifact, "nginx:latest"); + assert_eq!(entry.registry, "docker"); + assert_eq!(entry.source, "LOCAL"); + } + + #[test] + fn test_activity_log_push_and_len() { + let log = ActivityLog::new(10); + assert!(log.is_empty()); + assert_eq!(log.len(), 0); + + log.push(ActivityEntry::new( + ActionType::Push, + "test:v1".to_string(), + "docker", + "LOCAL", + )); + assert!(!log.is_empty()); + assert_eq!(log.len(), 1); + } + + #[test] + fn test_activity_log_recent() { + let log = ActivityLog::new(10); + for i in 0..5 { + log.push(ActivityEntry::new( + ActionType::Pull, + format!("image:{}", i), + "docker", + "LOCAL", + )); + } + + let recent = log.recent(3); + assert_eq!(recent.len(), 3); + // newest first + assert_eq!(recent[0].artifact, "image:4"); + assert_eq!(recent[1].artifact, "image:3"); + assert_eq!(recent[2].artifact, "image:2"); + } + + #[test] + fn test_activity_log_all() { + let log = ActivityLog::new(10); + for i in 0..3 { + log.push(ActivityEntry::new( + ActionType::Pull, + format!("pkg:{}", i), + "npm", + "PROXY", + )); + } + + let all = log.all(); + assert_eq!(all.len(), 3); + assert_eq!(all[0].artifact, "pkg:2"); // newest first + } + + #[test] + fn test_activity_log_bounded_size() { + let log = ActivityLog::new(3); + for i in 0..5 { + log.push(ActivityEntry::new( + ActionType::Pull, + format!("item:{}", i), + "cargo", + "CACHE", + )); + } + + assert_eq!(log.len(), 3); + let all = log.all(); + // oldest entries should be dropped + assert_eq!(all[0].artifact, "item:4"); + assert_eq!(all[1].artifact, "item:3"); + assert_eq!(all[2].artifact, "item:2"); + } + + #[test] + fn test_activity_log_recent_more_than_available() { + let log = ActivityLog::new(10); + log.push(ActivityEntry::new( + ActionType::Push, + "one".to_string(), + "maven", + "LOCAL", + )); + + let recent = log.recent(100); + assert_eq!(recent.len(), 1); + } + + #[test] + fn test_activity_log_default() { + let log = ActivityLog::default(); + assert!(log.is_empty()); + // default capacity is 50 + for i in 0..60 { + log.push(ActivityEntry::new( + ActionType::Pull, + format!("x:{}", i), + "docker", + "LOCAL", + )); + } + assert_eq!(log.len(), 50); + } +} diff --git a/nora-registry/src/audit.rs b/nora-registry/src/audit.rs index fd15dcc..597f29e 100644 --- a/nora-registry/src/audit.rs +++ b/nora-registry/src/audit.rs @@ -71,3 +71,69 @@ impl AuditLog { &self.path } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_audit_entry_new() { + let entry = AuditEntry::new( + "push", + "admin", + "nginx:latest", + "docker", + "uploaded manifest", + ); + assert_eq!(entry.action, "push"); + assert_eq!(entry.actor, "admin"); + assert_eq!(entry.artifact, "nginx:latest"); + assert_eq!(entry.registry, "docker"); + assert_eq!(entry.detail, "uploaded manifest"); + } + + #[test] + fn test_audit_log_new_and_path() { + let tmp = TempDir::new().unwrap(); + let log = AuditLog::new(tmp.path().to_str().unwrap()); + assert!(log.path().ends_with("audit.jsonl")); + } + + #[test] + fn test_audit_log_write_entry() { + let tmp = TempDir::new().unwrap(); + let log = AuditLog::new(tmp.path().to_str().unwrap()); + + let entry = AuditEntry::new("pull", "user1", "lodash", "npm", "downloaded"); + log.log(entry); + + // Verify file contains the entry + let content = std::fs::read_to_string(log.path()).unwrap(); + assert!(content.contains(r#""action":"pull""#)); + assert!(content.contains(r#""actor":"user1""#)); + assert!(content.contains(r#""artifact":"lodash""#)); + } + + #[test] + fn test_audit_log_multiple_entries() { + let tmp = TempDir::new().unwrap(); + let log = AuditLog::new(tmp.path().to_str().unwrap()); + + log.log(AuditEntry::new("push", "admin", "a", "docker", "")); + log.log(AuditEntry::new("pull", "user", "b", "npm", "")); + log.log(AuditEntry::new("delete", "admin", "c", "maven", "")); + + let content = std::fs::read_to_string(log.path()).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 3); + } + + #[test] + fn test_audit_entry_serialization() { + let entry = AuditEntry::new("push", "ci", "app:v1", "docker", "ci build"); + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains(r#""action":"push""#)); + assert!(json.contains(r#""ts":""#)); + } +} diff --git a/nora-registry/src/config.rs b/nora-registry/src/config.rs index c71da4d..bca19b0 100644 --- a/nora-registry/src/config.rs +++ b/nora-registry/src/config.rs @@ -666,4 +666,348 @@ mod tests { assert_eq!(config.rate_limit.upload_burst, 1000); assert_eq!(config.rate_limit.auth_burst, 5); // default } + + #[test] + fn test_basic_auth_header() { + let header = basic_auth_header("user:pass"); + assert_eq!(header, "Basic dXNlcjpwYXNz"); + } + + #[test] + fn test_basic_auth_header_empty() { + let header = basic_auth_header(""); + assert!(header.starts_with("Basic ")); + } + + #[test] + fn test_config_default() { + let config = Config::default(); + assert_eq!(config.server.host, "127.0.0.1"); + assert_eq!(config.server.port, 4000); + assert_eq!(config.server.body_limit_mb, 2048); + assert!(config.server.public_url.is_none()); + assert_eq!(config.storage.path, "data/storage"); + assert_eq!(config.storage.mode, StorageMode::Local); + assert_eq!(config.storage.bucket, "registry"); + assert_eq!(config.storage.s3_region, "us-east-1"); + assert!(!config.auth.enabled); + assert_eq!(config.auth.htpasswd_file, "users.htpasswd"); + assert_eq!(config.auth.token_storage, "data/tokens"); + } + + #[test] + fn test_maven_config_default() { + let m = MavenConfig::default(); + assert_eq!(m.proxy_timeout, 30); + assert_eq!(m.proxies.len(), 1); + assert_eq!(m.proxies[0].url(), "https://repo1.maven.org/maven2"); + assert!(m.proxies[0].auth().is_none()); + } + + #[test] + fn test_npm_config_default() { + let n = NpmConfig::default(); + assert_eq!(n.proxy, Some("https://registry.npmjs.org".to_string())); + assert!(n.proxy_auth.is_none()); + assert_eq!(n.proxy_timeout, 30); + assert_eq!(n.metadata_ttl, 300); + } + + #[test] + fn test_pypi_config_default() { + let p = PypiConfig::default(); + assert_eq!(p.proxy, Some("https://pypi.org/simple/".to_string())); + assert!(p.proxy_auth.is_none()); + assert_eq!(p.proxy_timeout, 30); + } + + #[test] + fn test_docker_config_default() { + let d = DockerConfig::default(); + assert_eq!(d.proxy_timeout, 60); + assert_eq!(d.upstreams.len(), 1); + assert_eq!(d.upstreams[0].url, "https://registry-1.docker.io"); + assert!(d.upstreams[0].auth.is_none()); + } + + #[test] + fn test_raw_config_default() { + let r = RawConfig::default(); + assert!(r.enabled); + assert_eq!(r.max_file_size, 104_857_600); + } + + #[test] + fn test_auth_config_default() { + let a = AuthConfig::default(); + assert!(!a.enabled); + assert_eq!(a.htpasswd_file, "users.htpasswd"); + assert_eq!(a.token_storage, "data/tokens"); + } + + #[test] + fn test_maven_proxy_entry_simple() { + let entry = MavenProxyEntry::Simple("https://repo.example.com".to_string()); + assert_eq!(entry.url(), "https://repo.example.com"); + assert!(entry.auth().is_none()); + } + + #[test] + fn test_maven_proxy_entry_full() { + let entry = MavenProxyEntry::Full(MavenProxy { + url: "https://private.repo.com".to_string(), + auth: Some("user:secret".to_string()), + }); + assert_eq!(entry.url(), "https://private.repo.com"); + assert_eq!(entry.auth(), Some("user:secret")); + } + + #[test] + fn test_maven_proxy_entry_full_no_auth() { + let entry = MavenProxyEntry::Full(MavenProxy { + url: "https://repo.com".to_string(), + auth: None, + }); + assert_eq!(entry.url(), "https://repo.com"); + assert!(entry.auth().is_none()); + } + + #[test] + fn test_storage_mode_default() { + let mode = StorageMode::default(); + assert_eq!(mode, StorageMode::Local); + } + + #[test] + fn test_env_override_server() { + let mut config = Config::default(); + std::env::set_var("NORA_HOST", "0.0.0.0"); + std::env::set_var("NORA_PORT", "8080"); + std::env::set_var("NORA_PUBLIC_URL", "registry.example.com"); + std::env::set_var("NORA_BODY_LIMIT_MB", "4096"); + config.apply_env_overrides(); + assert_eq!(config.server.host, "0.0.0.0"); + assert_eq!(config.server.port, 8080); + assert_eq!( + config.server.public_url, + Some("registry.example.com".to_string()) + ); + assert_eq!(config.server.body_limit_mb, 4096); + std::env::remove_var("NORA_HOST"); + std::env::remove_var("NORA_PORT"); + std::env::remove_var("NORA_PUBLIC_URL"); + std::env::remove_var("NORA_BODY_LIMIT_MB"); + } + + #[test] + fn test_env_override_storage() { + let mut config = Config::default(); + std::env::set_var("NORA_STORAGE_MODE", "s3"); + std::env::set_var("NORA_STORAGE_PATH", "/data/nora"); + std::env::set_var("NORA_STORAGE_BUCKET", "my-bucket"); + std::env::set_var("NORA_STORAGE_S3_REGION", "eu-west-1"); + config.apply_env_overrides(); + assert_eq!(config.storage.mode, StorageMode::S3); + assert_eq!(config.storage.path, "/data/nora"); + assert_eq!(config.storage.bucket, "my-bucket"); + assert_eq!(config.storage.s3_region, "eu-west-1"); + std::env::remove_var("NORA_STORAGE_MODE"); + std::env::remove_var("NORA_STORAGE_PATH"); + std::env::remove_var("NORA_STORAGE_BUCKET"); + std::env::remove_var("NORA_STORAGE_S3_REGION"); + } + + #[test] + fn test_env_override_auth() { + let mut config = Config::default(); + std::env::set_var("NORA_AUTH_ENABLED", "true"); + std::env::set_var("NORA_AUTH_HTPASSWD_FILE", "/etc/nora/users"); + std::env::set_var("NORA_AUTH_TOKEN_STORAGE", "/data/tokens"); + config.apply_env_overrides(); + assert!(config.auth.enabled); + assert_eq!(config.auth.htpasswd_file, "/etc/nora/users"); + assert_eq!(config.auth.token_storage, "/data/tokens"); + std::env::remove_var("NORA_AUTH_ENABLED"); + std::env::remove_var("NORA_AUTH_HTPASSWD_FILE"); + std::env::remove_var("NORA_AUTH_TOKEN_STORAGE"); + } + + #[test] + fn test_env_override_maven_proxies() { + let mut config = Config::default(); + std::env::set_var( + "NORA_MAVEN_PROXIES", + "https://repo1.com,https://repo2.com|user:pass", + ); + config.apply_env_overrides(); + assert_eq!(config.maven.proxies.len(), 2); + assert_eq!(config.maven.proxies[0].url(), "https://repo1.com"); + assert!(config.maven.proxies[0].auth().is_none()); + assert_eq!(config.maven.proxies[1].url(), "https://repo2.com"); + assert_eq!(config.maven.proxies[1].auth(), Some("user:pass")); + std::env::remove_var("NORA_MAVEN_PROXIES"); + } + + #[test] + fn test_env_override_docker_upstreams() { + let mut config = Config::default(); + std::env::set_var( + "NORA_DOCKER_UPSTREAMS", + "https://mirror.gcr.io,https://private.io|token123", + ); + config.apply_env_overrides(); + assert_eq!(config.docker.upstreams.len(), 2); + assert_eq!(config.docker.upstreams[0].url, "https://mirror.gcr.io"); + assert!(config.docker.upstreams[0].auth.is_none()); + assert_eq!(config.docker.upstreams[1].url, "https://private.io"); + assert_eq!( + config.docker.upstreams[1].auth, + Some("token123".to_string()) + ); + std::env::remove_var("NORA_DOCKER_UPSTREAMS"); + } + + #[test] + fn test_env_override_npm() { + let mut config = Config::default(); + std::env::set_var("NORA_NPM_PROXY", "https://npm.company.com"); + std::env::set_var("NORA_NPM_PROXY_AUTH", "user:token"); + std::env::set_var("NORA_NPM_PROXY_TIMEOUT", "60"); + std::env::set_var("NORA_NPM_METADATA_TTL", "600"); + config.apply_env_overrides(); + assert_eq!( + config.npm.proxy, + Some("https://npm.company.com".to_string()) + ); + assert_eq!(config.npm.proxy_auth, Some("user:token".to_string())); + assert_eq!(config.npm.proxy_timeout, 60); + assert_eq!(config.npm.metadata_ttl, 600); + std::env::remove_var("NORA_NPM_PROXY"); + std::env::remove_var("NORA_NPM_PROXY_AUTH"); + std::env::remove_var("NORA_NPM_PROXY_TIMEOUT"); + std::env::remove_var("NORA_NPM_METADATA_TTL"); + } + + #[test] + fn test_env_override_raw() { + let mut config = Config::default(); + std::env::set_var("NORA_RAW_ENABLED", "false"); + std::env::set_var("NORA_RAW_MAX_FILE_SIZE", "524288000"); + config.apply_env_overrides(); + assert!(!config.raw.enabled); + assert_eq!(config.raw.max_file_size, 524288000); + std::env::remove_var("NORA_RAW_ENABLED"); + std::env::remove_var("NORA_RAW_MAX_FILE_SIZE"); + } + + #[test] + fn test_env_override_rate_limit() { + let mut config = Config::default(); + std::env::set_var("NORA_RATE_LIMIT_ENABLED", "false"); + std::env::set_var("NORA_RATE_LIMIT_AUTH_RPS", "10"); + std::env::set_var("NORA_RATE_LIMIT_GENERAL_BURST", "500"); + config.apply_env_overrides(); + assert!(!config.rate_limit.enabled); + assert_eq!(config.rate_limit.auth_rps, 10); + assert_eq!(config.rate_limit.general_burst, 500); + std::env::remove_var("NORA_RATE_LIMIT_ENABLED"); + std::env::remove_var("NORA_RATE_LIMIT_AUTH_RPS"); + std::env::remove_var("NORA_RATE_LIMIT_GENERAL_BURST"); + } + + #[test] + fn test_config_from_toml_full() { + let toml = r#" + [server] + host = "0.0.0.0" + port = 8080 + public_url = "nora.example.com" + body_limit_mb = 4096 + + [storage] + mode = "s3" + path = "/data" + s3_url = "http://minio:9000" + bucket = "artifacts" + s3_region = "eu-central-1" + + [auth] + enabled = true + htpasswd_file = "/etc/nora/users.htpasswd" + + [raw] + enabled = false + max_file_size = 500000000 + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.server.host, "0.0.0.0"); + assert_eq!(config.server.port, 8080); + assert_eq!( + config.server.public_url, + Some("nora.example.com".to_string()) + ); + assert_eq!(config.server.body_limit_mb, 4096); + assert_eq!(config.storage.mode, StorageMode::S3); + assert_eq!(config.storage.s3_url, "http://minio:9000"); + assert_eq!(config.storage.bucket, "artifacts"); + assert!(config.auth.enabled); + assert!(!config.raw.enabled); + assert_eq!(config.raw.max_file_size, 500000000); + } + + #[test] + fn test_config_from_toml_minimal() { + let toml = r#" + [server] + host = "127.0.0.1" + port = 4000 + + [storage] + mode = "local" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + // Defaults should be filled + assert_eq!(config.storage.path, "data/storage"); + assert_eq!(config.maven.proxies.len(), 1); + assert_eq!( + config.npm.proxy, + Some("https://registry.npmjs.org".to_string()) + ); + assert_eq!(config.docker.upstreams.len(), 1); + assert!(config.raw.enabled); + assert!(!config.auth.enabled); + } + + #[test] + fn test_config_toml_docker_upstreams() { + let toml = r#" + [server] + host = "127.0.0.1" + port = 4000 + + [storage] + mode = "local" + + [docker] + proxy_timeout = 120 + + [[docker.upstreams]] + url = "https://mirror.gcr.io" + + [[docker.upstreams]] + url = "https://private.registry.io" + auth = "user:pass" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.docker.proxy_timeout, 120); + assert_eq!(config.docker.upstreams.len(), 2); + assert!(config.docker.upstreams[0].auth.is_none()); + assert_eq!( + config.docker.upstreams[1].auth, + Some("user:pass".to_string()) + ); + } } diff --git a/nora-registry/src/dashboard_metrics.rs b/nora-registry/src/dashboard_metrics.rs index 1f4dd94..e14bb3b 100644 --- a/nora-registry/src/dashboard_metrics.rs +++ b/nora-registry/src/dashboard_metrics.rs @@ -216,3 +216,146 @@ impl Default for DashboardMetrics { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_new_defaults() { + let m = DashboardMetrics::new(); + assert_eq!(m.downloads.load(Ordering::Relaxed), 0); + assert_eq!(m.uploads.load(Ordering::Relaxed), 0); + assert_eq!(m.cache_hits.load(Ordering::Relaxed), 0); + assert_eq!(m.cache_misses.load(Ordering::Relaxed), 0); + } + + #[test] + fn test_record_download_all_registries() { + let m = DashboardMetrics::new(); + for reg in &["docker", "npm", "maven", "cargo", "pypi", "raw"] { + m.record_download(reg); + } + assert_eq!(m.downloads.load(Ordering::Relaxed), 6); + assert_eq!(m.docker_downloads.load(Ordering::Relaxed), 1); + assert_eq!(m.npm_downloads.load(Ordering::Relaxed), 1); + assert_eq!(m.maven_downloads.load(Ordering::Relaxed), 1); + assert_eq!(m.cargo_downloads.load(Ordering::Relaxed), 1); + assert_eq!(m.pypi_downloads.load(Ordering::Relaxed), 1); + assert_eq!(m.raw_downloads.load(Ordering::Relaxed), 1); + } + + #[test] + fn test_record_download_unknown_registry() { + let m = DashboardMetrics::new(); + m.record_download("unknown"); + assert_eq!(m.downloads.load(Ordering::Relaxed), 1); + // no per-registry counter should increment + assert_eq!(m.docker_downloads.load(Ordering::Relaxed), 0); + } + + #[test] + fn test_record_upload() { + let m = DashboardMetrics::new(); + m.record_upload("docker"); + m.record_upload("maven"); + m.record_upload("raw"); + assert_eq!(m.uploads.load(Ordering::Relaxed), 3); + assert_eq!(m.docker_uploads.load(Ordering::Relaxed), 1); + assert_eq!(m.maven_uploads.load(Ordering::Relaxed), 1); + assert_eq!(m.raw_uploads.load(Ordering::Relaxed), 1); + } + + #[test] + fn test_record_upload_unknown_registry() { + let m = DashboardMetrics::new(); + m.record_upload("npm"); // npm has no upload counter + assert_eq!(m.uploads.load(Ordering::Relaxed), 1); + } + + #[test] + fn test_cache_hit_rate_zero() { + let m = DashboardMetrics::new(); + assert_eq!(m.cache_hit_rate(), 0.0); + } + + #[test] + fn test_cache_hit_rate_all_hits() { + let m = DashboardMetrics::new(); + m.record_cache_hit(); + m.record_cache_hit(); + assert_eq!(m.cache_hit_rate(), 100.0); + } + + #[test] + fn test_cache_hit_rate_mixed() { + let m = DashboardMetrics::new(); + m.record_cache_hit(); + m.record_cache_miss(); + assert_eq!(m.cache_hit_rate(), 50.0); + } + + #[test] + fn test_get_registry_downloads() { + let m = DashboardMetrics::new(); + m.record_download("docker"); + m.record_download("docker"); + m.record_download("npm"); + assert_eq!(m.get_registry_downloads("docker"), 2); + assert_eq!(m.get_registry_downloads("npm"), 1); + assert_eq!(m.get_registry_downloads("cargo"), 0); + assert_eq!(m.get_registry_downloads("unknown"), 0); + } + + #[test] + fn test_get_registry_uploads() { + let m = DashboardMetrics::new(); + m.record_upload("docker"); + assert_eq!(m.get_registry_uploads("docker"), 1); + assert_eq!(m.get_registry_uploads("maven"), 0); + assert_eq!(m.get_registry_uploads("unknown"), 0); + } + + #[test] + fn test_persistence_save_and_load() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + + // Create metrics, record some data, save + { + let m = DashboardMetrics::with_persistence(path); + m.record_download("docker"); + m.record_download("docker"); + m.record_upload("maven"); + m.record_cache_hit(); + m.save(); + } + + // Load in new instance + { + let m = DashboardMetrics::with_persistence(path); + assert_eq!(m.downloads.load(Ordering::Relaxed), 2); + assert_eq!(m.uploads.load(Ordering::Relaxed), 1); + assert_eq!(m.docker_downloads.load(Ordering::Relaxed), 2); + assert_eq!(m.maven_uploads.load(Ordering::Relaxed), 1); + assert_eq!(m.cache_hits.load(Ordering::Relaxed), 1); + } + } + + #[test] + fn test_persistence_missing_file() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().to_str().unwrap(); + + // Should work even without existing metrics.json + let m = DashboardMetrics::with_persistence(path); + assert_eq!(m.downloads.load(Ordering::Relaxed), 0); + } + + #[test] + fn test_default() { + let m = DashboardMetrics::default(); + assert_eq!(m.downloads.load(Ordering::Relaxed), 0); + } +} diff --git a/nora-registry/src/error.rs b/nora-registry/src/error.rs index 8a1a83e..4b06a9b 100644 --- a/nora-registry/src/error.rs +++ b/nora-registry/src/error.rs @@ -124,4 +124,77 @@ mod tests { let err = AppError::NotFound("image not found".to_string()); assert_eq!(err.to_string(), "Not found: image not found"); } + + #[test] + fn test_error_constructors() { + let err = AppError::not_found("missing"); + assert!(matches!(err, AppError::NotFound(_))); + assert_eq!(err.to_string(), "Not found: missing"); + + let err = AppError::bad_request("invalid input"); + assert!(matches!(err, AppError::BadRequest(_))); + assert_eq!(err.to_string(), "Bad request: invalid input"); + + let err = AppError::unauthorized("no token"); + assert!(matches!(err, AppError::Unauthorized(_))); + assert_eq!(err.to_string(), "Unauthorized: no token"); + + let err = AppError::internal("db crashed"); + assert!(matches!(err, AppError::Internal(_))); + assert_eq!(err.to_string(), "Internal error: db crashed"); + } + + #[test] + fn test_error_display_storage() { + let err = AppError::Storage(StorageError::NotFound); + assert!(err.to_string().contains("Storage error")); + } + + #[test] + fn test_error_display_validation() { + let err = AppError::Validation(ValidationError::PathTraversal); + assert!(err.to_string().contains("Validation error")); + } + + #[test] + fn test_error_into_response_not_found() { + let err = AppError::NotFound("gone".to_string()); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn test_error_into_response_bad_request() { + let err = AppError::BadRequest("bad".to_string()); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn test_error_into_response_unauthorized() { + let err = AppError::Unauthorized("nope".to_string()); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn test_error_into_response_internal() { + let err = AppError::Internal("boom".to_string()); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn test_error_into_response_storage_not_found() { + let err = AppError::Storage(StorageError::NotFound); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn test_error_into_response_validation() { + let err = AppError::Validation(ValidationError::EmptyInput); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } } diff --git a/nora-registry/src/metrics.rs b/nora-registry/src/metrics.rs index 53c2e50..d62d408 100644 --- a/nora-registry/src/metrics.rs +++ b/nora-registry/src/metrics.rs @@ -148,3 +148,56 @@ pub fn record_storage_op(operation: &str, success: bool) { .with_label_values(&[operation, status]) .inc(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_registry_docker() { + assert_eq!(detect_registry("/v2/nginx/manifests/latest"), "docker"); + assert_eq!(detect_registry("/v2/"), "docker"); + assert_eq!( + detect_registry("/v2/library/alpine/blobs/sha256:abc"), + "docker" + ); + } + + #[test] + fn test_detect_registry_maven() { + assert_eq!(detect_registry("/maven2/com/example/artifact"), "maven"); + } + + #[test] + fn test_detect_registry_npm() { + assert_eq!(detect_registry("/npm/lodash"), "npm"); + assert_eq!(detect_registry("/npm/@scope/package"), "npm"); + } + + #[test] + fn test_detect_registry_cargo() { + assert_eq!(detect_registry("/cargo/api/v1/crates"), "cargo"); + } + + #[test] + fn test_detect_registry_pypi() { + assert_eq!(detect_registry("/simple/requests/"), "pypi"); + assert_eq!( + detect_registry("/packages/requests/1.0/requests-1.0.tar.gz"), + "pypi" + ); + } + + #[test] + fn test_detect_registry_ui() { + assert_eq!(detect_registry("/ui/dashboard"), "ui"); + assert_eq!(detect_registry("/ui"), "ui"); + } + + #[test] + fn test_detect_registry_other() { + assert_eq!(detect_registry("/health"), "other"); + assert_eq!(detect_registry("/ready"), "other"); + assert_eq!(detect_registry("/unknown/path"), "other"); + } +} diff --git a/nora-registry/src/repo_index.rs b/nora-registry/src/repo_index.rs index d269f41..aa8ce27 100644 --- a/nora-registry/src/repo_index.rs +++ b/nora-registry/src/repo_index.rs @@ -361,3 +361,164 @@ pub fn paginate(data: &[T], page: usize, limit: usize) -> (Vec, usi let end = (start + limit).min(total); (data[start..end].to_vec(), total) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_paginate_first_page() { + let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + let (page, total) = paginate(&data, 1, 3); + assert_eq!(page, vec![1, 2, 3]); + assert_eq!(total, 10); + } + + #[test] + fn test_paginate_second_page() { + let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + let (page, total) = paginate(&data, 2, 3); + assert_eq!(page, vec![4, 5, 6]); + assert_eq!(total, 10); + } + + #[test] + fn test_paginate_last_page_partial() { + let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + let (page, total) = paginate(&data, 4, 3); + assert_eq!(page, vec![10]); + assert_eq!(total, 10); + } + + #[test] + fn test_paginate_beyond_range() { + let data = vec![1, 2, 3]; + let (page, total) = paginate(&data, 5, 3); + assert!(page.is_empty()); + assert_eq!(total, 3); + } + + #[test] + fn test_paginate_empty_data() { + let data: Vec = vec![]; + let (page, total) = paginate(&data, 1, 10); + assert!(page.is_empty()); + assert_eq!(total, 0); + } + + #[test] + fn test_paginate_page_zero() { + // page 0 with saturating_sub becomes 0, so start = 0 + let data = vec![1, 2, 3]; + let (page, _) = paginate(&data, 0, 2); + assert_eq!(page, vec![1, 2]); + } + + #[test] + fn test_paginate_large_limit() { + let data = vec![1, 2, 3]; + let (page, total) = paginate(&data, 1, 100); + assert_eq!(page, vec![1, 2, 3]); + assert_eq!(total, 3); + } + + #[test] + fn test_registry_index_new() { + let idx = RegistryIndex::new(); + assert_eq!(idx.count(), 0); + assert!(idx.is_dirty()); + } + + #[test] + fn test_registry_index_invalidate() { + let idx = RegistryIndex::new(); + // Initially dirty + assert!(idx.is_dirty()); + + // Set data clears dirty + idx.set(vec![RepoInfo { + name: "test".to_string(), + versions: 1, + size: 100, + updated: "2026-01-01".to_string(), + }]); + assert!(!idx.is_dirty()); + assert_eq!(idx.count(), 1); + + // Invalidate makes it dirty again + idx.invalidate(); + assert!(idx.is_dirty()); + } + + #[test] + fn test_registry_index_get_cached() { + let idx = RegistryIndex::new(); + idx.set(vec![ + RepoInfo { + name: "a".to_string(), + versions: 2, + size: 200, + updated: "today".to_string(), + }, + RepoInfo { + name: "b".to_string(), + versions: 1, + size: 100, + updated: "yesterday".to_string(), + }, + ]); + + let cached = idx.get_cached(); + assert_eq!(cached.len(), 2); + assert_eq!(cached[0].name, "a"); + } + + #[test] + fn test_registry_index_default() { + let idx = RegistryIndex::default(); + assert_eq!(idx.count(), 0); + } + + #[test] + fn test_repo_index_new() { + let idx = RepoIndex::new(); + let (d, m, n, c, p) = idx.counts(); + assert_eq!((d, m, n, c, p), (0, 0, 0, 0, 0)); + } + + #[test] + fn test_repo_index_invalidate() { + let idx = RepoIndex::new(); + // Should not panic for any registry + idx.invalidate("docker"); + idx.invalidate("maven"); + idx.invalidate("npm"); + idx.invalidate("cargo"); + idx.invalidate("pypi"); + idx.invalidate("unknown"); // should be a no-op + } + + #[test] + fn test_repo_index_default() { + let idx = RepoIndex::default(); + let (d, m, n, c, p) = idx.counts(); + assert_eq!((d, m, n, c, p), (0, 0, 0, 0, 0)); + } + + #[test] + fn test_to_sorted_vec() { + let mut map = std::collections::HashMap::new(); + map.insert("zebra".to_string(), (3usize, 100u64, 0u64)); + map.insert("alpha".to_string(), (1, 50, 1700000000)); + + let result = to_sorted_vec(map); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name, "alpha"); + assert_eq!(result[0].versions, 1); + assert_eq!(result[0].size, 50); + assert_ne!(result[0].updated, "N/A"); + assert_eq!(result[1].name, "zebra"); + assert_eq!(result[1].versions, 3); + assert_eq!(result[1].updated, "N/A"); // modified = 0 + } +}