From 7ed3444d86920221871c5fbbb7bed2fb0fcb6302 Mon Sep 17 00:00:00 2001 From: DevITWay Date: Mon, 26 Jan 2026 00:02:09 +0000 Subject: [PATCH] test: add comprehensive unit tests for storage and auth - LocalStorage tests: put/get, list, stat, health_check, nested dirs - S3Storage tests with wiremock HTTP mocking - Auth/htpasswd tests: loading, validation, public paths - Token lifecycle tests: create, verify, expire, revoke Total: 75 tests passing --- Cargo.lock | 407 ++++++++++++++++++++++++++++- nora-registry/Cargo.toml | 7 + nora-registry/src/auth.rs | 103 ++++++++ nora-registry/src/storage/local.rs | 109 ++++++++ nora-registry/src/storage/s3.rs | 181 +++++++++++++ nora-registry/src/tokens.rs | 178 ++++++++++++- 6 files changed, 973 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7e8ac5..1f64525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -85,6 +91,16 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -351,6 +367,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -361,6 +383,38 @@ dependencies = [ "typenum", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -415,6 +469,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filetime" version = "0.2.27" @@ -449,6 +509,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -458,6 +524,31 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -474,12 +565,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -492,14 +605,22 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -545,11 +666,64 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -557,6 +731,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -612,6 +792,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -641,6 +822,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -798,7 +992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -977,6 +1171,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nora-cli" version = "0.1.0" @@ -1002,6 +1208,7 @@ dependencies = [ "chrono", "clap", "flate2", + "governor", "httpdate", "indicatif", "lazy_static", @@ -1011,13 +1218,17 @@ dependencies = [ "serde_json", "sha2", "tar", + "tempfile", + "thiserror 2.0.18", "tokio", "toml", + "tower_governor", "tracing", "tracing-subscriber", "utoipa", "utoipa-swagger-ui", "uuid", + "wiremock", ] [[package]] @@ -1057,6 +1268,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -1104,6 +1325,26 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1170,6 +1411,21 @@ version = "2.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.31.0" @@ -1279,6 +1535,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1634,6 +1899,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1694,6 +1968,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1806,6 +2093,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1847,6 +2158,35 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tonic" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -1855,9 +2195,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -1893,6 +2236,23 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower_governor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http", + "pin-project", + "thiserror 2.0.18", + "tonic", + "tower", + "tracing", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2216,6 +2576,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2225,6 +2601,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2458,6 +2840,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/nora-registry/Cargo.toml b/nora-registry/Cargo.toml index cbcbdd7..9df3d13 100644 --- a/nora-registry/Cargo.toml +++ b/nora-registry/Cargo.toml @@ -38,3 +38,10 @@ tar = "0.4" flate2 = "1.0" indicatif = "0.17" chrono = { version = "0.4", features = ["serde"] } +thiserror = "2" +tower_governor = "0.8" +governor = "0.10" + +[dev-dependencies] +tempfile = "3" +wiremock = "0.6" diff --git a/nora-registry/src/auth.rs b/nora-registry/src/auth.rs index 74bc0b0..9b59334 100644 --- a/nora-registry/src/auth.rs +++ b/nora-registry/src/auth.rs @@ -313,3 +313,106 @@ pub fn token_routes() -> Router> { .route("/api/tokens/list", post(list_tokens)) .route("/api/tokens/revoke", post(revoke_token)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn create_test_htpasswd(entries: &[(&str, &str)]) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + for (username, password) in entries { + let hash = bcrypt::hash(password, 4).unwrap(); // cost=4 for speed in tests + writeln!(file, "{}:{}", username, hash).unwrap(); + } + file.flush().unwrap(); + file + } + + #[test] + fn test_htpasswd_loading() { + let file = create_test_htpasswd(&[("admin", "secret"), ("user", "password")]); + + let auth = HtpasswdAuth::from_file(file.path()).unwrap(); + let users = auth.list_users(); + assert_eq!(users.len(), 2); + assert!(users.contains(&"admin")); + assert!(users.contains(&"user")); + } + + #[test] + fn test_htpasswd_loading_empty_file() { + let file = NamedTempFile::new().unwrap(); + let auth = HtpasswdAuth::from_file(file.path()); + assert!(auth.is_none()); + } + + #[test] + fn test_htpasswd_loading_with_comments() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "# This is a comment").unwrap(); + writeln!(file, "").unwrap(); + let hash = bcrypt::hash("secret", 4).unwrap(); + writeln!(file, "admin:{}", hash).unwrap(); + file.flush().unwrap(); + + let auth = HtpasswdAuth::from_file(file.path()).unwrap(); + assert_eq!(auth.list_users().len(), 1); + } + + #[test] + fn test_authenticate_valid() { + let file = create_test_htpasswd(&[("test", "secret")]); + let auth = HtpasswdAuth::from_file(file.path()).unwrap(); + + assert!(auth.authenticate("test", "secret")); + } + + #[test] + fn test_authenticate_invalid_password() { + let file = create_test_htpasswd(&[("test", "secret")]); + let auth = HtpasswdAuth::from_file(file.path()).unwrap(); + + assert!(!auth.authenticate("test", "wrong")); + } + + #[test] + fn test_authenticate_unknown_user() { + let file = create_test_htpasswd(&[("test", "secret")]); + let auth = HtpasswdAuth::from_file(file.path()).unwrap(); + + assert!(!auth.authenticate("unknown", "secret")); + } + + #[test] + fn test_is_public_path() { + // Public paths + assert!(is_public_path("/")); + assert!(is_public_path("/health")); + assert!(is_public_path("/ready")); + assert!(is_public_path("/metrics")); + assert!(is_public_path("/v2/")); + assert!(is_public_path("/v2")); + assert!(is_public_path("/ui")); + assert!(is_public_path("/ui/dashboard")); + assert!(is_public_path("/api-docs")); + assert!(is_public_path("/api-docs/openapi.json")); + assert!(is_public_path("/api/ui/stats")); + assert!(is_public_path("/api/tokens")); + assert!(is_public_path("/api/tokens/list")); + + // Protected paths + assert!(!is_public_path("/v2/myimage/blobs/sha256:abc")); + assert!(!is_public_path("/v2/library/nginx/manifests/latest")); + assert!(!is_public_path("/maven2/com/example/artifact/1.0/artifact.jar")); + assert!(!is_public_path("/npm/lodash")); + } + + #[test] + fn test_hash_password() { + let hash = hash_password("test123").unwrap(); + assert!(hash.starts_with("$2")); + assert!(bcrypt::verify("test123", &hash).unwrap()); + } +} diff --git a/nora-registry/src/storage/local.rs b/nora-registry/src/storage/local.rs index b10f75f..86e884f 100644 --- a/nora-registry/src/storage/local.rs +++ b/nora-registry/src/storage/local.rs @@ -129,3 +129,112 @@ impl StorageBackend for LocalStorage { "local" } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn test_put_and_get() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + + storage.put("test/key", b"test data").await.unwrap(); + let data = storage.get("test/key").await.unwrap(); + assert_eq!(&*data, b"test data"); + } + + #[tokio::test] + async fn test_get_not_found() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + + let result = storage.get("nonexistent").await; + assert!(matches!(result, Err(StorageError::NotFound))); + } + + #[tokio::test] + async fn test_list_with_prefix() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + + storage.put("docker/image/blob1", b"data1").await.unwrap(); + storage.put("docker/image/blob2", b"data2").await.unwrap(); + storage.put("maven/artifact", b"data3").await.unwrap(); + + let docker_keys = storage.list("docker/").await; + assert_eq!(docker_keys.len(), 2); + assert!(docker_keys.iter().all(|k| k.starts_with("docker/"))); + + let all_keys = storage.list("").await; + assert_eq!(all_keys.len(), 3); + } + + #[tokio::test] + async fn test_stat() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + + storage.put("test", b"12345").await.unwrap(); + let meta = storage.stat("test").await.unwrap(); + assert_eq!(meta.size, 5); + assert!(meta.modified > 0); + } + + #[tokio::test] + async fn test_stat_not_found() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + + let meta = storage.stat("nonexistent").await; + assert!(meta.is_none()); + } + + #[tokio::test] + async fn test_health_check() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + assert!(storage.health_check().await); + } + + #[tokio::test] + async fn test_health_check_creates_directory() { + let temp_dir = TempDir::new().unwrap(); + let new_path = temp_dir.path().join("new_storage"); + let storage = LocalStorage::new(new_path.to_str().unwrap()); + + assert!(!new_path.exists()); + assert!(storage.health_check().await); + assert!(new_path.exists()); + } + + #[tokio::test] + async fn test_nested_directory_creation() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + + storage.put("a/b/c/d/e/file", b"deep").await.unwrap(); + let data = storage.get("a/b/c/d/e/file").await.unwrap(); + assert_eq!(&*data, b"deep"); + } + + #[tokio::test] + async fn test_overwrite() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + + storage.put("key", b"original").await.unwrap(); + storage.put("key", b"updated").await.unwrap(); + + let data = storage.get("key").await.unwrap(); + assert_eq!(&*data, b"updated"); + } + + #[test] + fn test_backend_name() { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path().to_str().unwrap()); + assert_eq!(storage.backend_name(), "local"); + } +} diff --git a/nora-registry/src/storage/s3.rs b/nora-registry/src/storage/s3.rs index e37d8d9..5057d2b 100644 --- a/nora-registry/src/storage/s3.rs +++ b/nora-registry/src/storage/s3.rs @@ -127,3 +127,184 @@ impl StorageBackend for S3Storage { "s3" } } + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn test_put_success() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("PUT")) + .and(path("/test-bucket/test-key")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + let result = storage.put("test-key", b"data").await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_put_failure() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("PUT")) + .and(path("/test-bucket/test-key")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock_server) + .await; + + let result = storage.put("test-key", b"data").await; + assert!(matches!(result, Err(StorageError::Network(_)))); + } + + #[tokio::test] + async fn test_get_success() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("GET")) + .and(path("/test-bucket/test-key")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"test data".to_vec())) + .mount(&mock_server) + .await; + + let data = storage.get("test-key").await.unwrap(); + assert_eq!(&*data, b"test data"); + } + + #[tokio::test] + async fn test_get_not_found() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("GET")) + .and(path("/test-bucket/missing")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let result = storage.get("missing").await; + assert!(matches!(result, Err(StorageError::NotFound))); + } + + #[tokio::test] + async fn test_list() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + let xml_response = r#" + + docker/image1 + docker/image2 + maven/artifact + "#; + + Mock::given(method("GET")) + .and(path("/test-bucket")) + .respond_with(ResponseTemplate::new(200).set_body_string(xml_response)) + .mount(&mock_server) + .await; + + let keys = storage.list("docker/").await; + assert_eq!(keys.len(), 2); + assert!(keys.iter().all(|k| k.starts_with("docker/"))); + } + + #[tokio::test] + async fn test_stat_success() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("HEAD")) + .and(path("/test-bucket/test-key")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "1234") + .insert_header("last-modified", "Sun, 06 Nov 1994 08:49:37 GMT"), + ) + .mount(&mock_server) + .await; + + let meta = storage.stat("test-key").await.unwrap(); + assert_eq!(meta.size, 1234); + assert!(meta.modified > 0); + } + + #[tokio::test] + async fn test_stat_not_found() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("HEAD")) + .and(path("/test-bucket/missing")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + let meta = storage.stat("missing").await; + assert!(meta.is_none()); + } + + #[tokio::test] + async fn test_health_check_healthy() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("HEAD")) + .and(path("/test-bucket")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + assert!(storage.health_check().await); + } + + #[tokio::test] + async fn test_health_check_bucket_not_found_is_ok() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("HEAD")) + .and(path("/test-bucket")) + .respond_with(ResponseTemplate::new(404)) + .mount(&mock_server) + .await; + + // 404 is OK for health check (bucket may be empty) + assert!(storage.health_check().await); + } + + #[tokio::test] + async fn test_health_check_server_error() { + let mock_server = MockServer::start().await; + let storage = S3Storage::new(&mock_server.uri(), "test-bucket"); + + Mock::given(method("HEAD")) + .and(path("/test-bucket")) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock_server) + .await; + + assert!(!storage.health_check().await); + } + + #[test] + fn test_backend_name() { + let storage = S3Storage::new("http://localhost:9000", "bucket"); + assert_eq!(storage.backend_name(), "s3"); + } + + #[test] + fn test_parse_s3_keys() { + let xml = r#"docker/adocker/bmaven/c"#; + let keys = S3Storage::parse_s3_keys(xml, "docker/"); + assert_eq!(keys, vec!["docker/a", "docker/b"]); + } +} diff --git a/nora-registry/src/tokens.rs b/nora-registry/src/tokens.rs index 81db839..0f2c1b4 100644 --- a/nora-registry/src/tokens.rs +++ b/nora-registry/src/tokens.rs @@ -3,6 +3,7 @@ use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; use uuid::Uuid; const TOKEN_PREFIX: &str = "nra_"; @@ -180,23 +181,178 @@ fn hash_token(token: &str) -> String { format!("{:x}", hasher.finalize()) } -#[derive(Debug)] +#[derive(Debug, Error)] pub enum TokenError { + #[error("Invalid token format")] InvalidFormat, + + #[error("Token not found")] NotFound, + + #[error("Token expired")] Expired, + + #[error("Storage error: {0}")] Storage(String), } -impl std::fmt::Display for TokenError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidFormat => write!(f, "Invalid token format"), - Self::NotFound => write!(f, "Token not found"), - Self::Expired => write!(f, "Token expired"), - Self::Storage(msg) => write!(f, "Storage error: {}", msg), - } +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_create_token() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let token = store + .create_token("testuser", 30, Some("Test token".to_string())) + .unwrap(); + + assert!(token.starts_with("nra_")); + assert_eq!(token.len(), 4 + 32); // prefix + uuid without dashes + } + + #[test] + fn test_verify_valid_token() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let token = store.create_token("testuser", 30, None).unwrap(); + let user = store.verify_token(&token).unwrap(); + + assert_eq!(user, "testuser"); + } + + #[test] + fn test_verify_invalid_format() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let result = store.verify_token("invalid_token"); + assert!(matches!(result, Err(TokenError::InvalidFormat))); + } + + #[test] + fn test_verify_not_found() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let result = store.verify_token("nra_00000000000000000000000000000000"); + assert!(matches!(result, Err(TokenError::NotFound))); + } + + #[test] + fn test_verify_expired_token() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + // Create token and manually set it as expired + let token = store.create_token("testuser", 1, None).unwrap(); + let token_hash = hash_token(&token); + let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16])); + + // Read and modify the token to be expired + let content = std::fs::read_to_string(&file_path).unwrap(); + let mut info: TokenInfo = serde_json::from_str(&content).unwrap(); + info.expires_at = 0; // Set to epoch (definitely expired) + std::fs::write(&file_path, serde_json::to_string(&info).unwrap()).unwrap(); + + // Token should now be expired + let result = store.verify_token(&token); + assert!(matches!(result, Err(TokenError::Expired))); + } + + #[test] + fn test_list_tokens() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + store.create_token("user1", 30, None).unwrap(); + store.create_token("user1", 30, None).unwrap(); + store.create_token("user2", 30, None).unwrap(); + + let user1_tokens = store.list_tokens("user1"); + assert_eq!(user1_tokens.len(), 2); + + let user2_tokens = store.list_tokens("user2"); + assert_eq!(user2_tokens.len(), 1); + + let unknown_tokens = store.list_tokens("unknown"); + assert_eq!(unknown_tokens.len(), 0); + } + + #[test] + fn test_revoke_token() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let token = store.create_token("testuser", 30, None).unwrap(); + let token_hash = hash_token(&token); + let hash_prefix = &token_hash[..16]; + + // Verify token works + assert!(store.verify_token(&token).is_ok()); + + // Revoke + store.revoke_token(hash_prefix).unwrap(); + + // Verify token no longer works + let result = store.verify_token(&token); + assert!(matches!(result, Err(TokenError::NotFound))); + } + + #[test] + fn test_revoke_nonexistent_token() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let result = store.revoke_token("nonexistent12345"); + assert!(matches!(result, Err(TokenError::NotFound))); + } + + #[test] + fn test_revoke_all_for_user() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + store.create_token("user1", 30, None).unwrap(); + store.create_token("user1", 30, None).unwrap(); + store.create_token("user2", 30, None).unwrap(); + + let revoked = store.revoke_all_for_user("user1"); + assert_eq!(revoked, 2); + + assert_eq!(store.list_tokens("user1").len(), 0); + assert_eq!(store.list_tokens("user2").len(), 1); + } + + #[test] + fn test_token_updates_last_used() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + let token = store.create_token("testuser", 30, None).unwrap(); + + // First verification + store.verify_token(&token).unwrap(); + + // Check last_used is set + let tokens = store.list_tokens("testuser"); + assert!(tokens[0].last_used.is_some()); + } + + #[test] + fn test_token_with_description() { + let temp_dir = TempDir::new().unwrap(); + let store = TokenStore::new(temp_dir.path()); + + store + .create_token("testuser", 30, Some("CI/CD Pipeline".to_string())) + .unwrap(); + + let tokens = store.list_tokens("testuser"); + assert_eq!(tokens[0].description, Some("CI/CD Pipeline".to_string())); } } - -impl std::error::Error for TokenError {}