diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8253c8c..b13b212 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -64,8 +64,33 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Run tests + - name: Run unit tests run: cargo test --verbose --all-targets + - name: Run integration tests + run: cargo test --verbose --test '*' + + e2e-test: + name: E2E Tests + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + - name: Build release binaries + run: cargo build --release + - name: Run E2E tests (without Docker) + run: cargo test --verbose --test '*' -- --test-threads=1 + - name: Run Docker client tests + run: cargo test --verbose --test docker_client --features docker-tests -- --test-threads=1 + continue-on-error: true + - name: Upload test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-logs + path: | + target/debug/ + /tmp/grain-test-*/ + retention-days: 3 integration-test: name: Integration Test diff --git a/Cargo.toml b/Cargo.toml index 208aa73..b3212bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,12 @@ reqwest = { version = "0.12", features = ["blocking", "json"] } lazy_static = "1.4" regex = "1.10" prometheus = "0.13" + +[dev-dependencies] +tempfile = "3.8" +tokio-test = "0.4" +serial_test = "3.0" + +[features] +default = [] +docker-tests = [] diff --git a/tests/admin_api.rs b/tests/admin_api.rs new file mode 100644 index 0000000..8dff523 --- /dev/null +++ b/tests/admin_api.rs @@ -0,0 +1,322 @@ +mod common; + +use common::*; +use serial_test::serial; + +#[test] +#[serial] +fn test_admin_list_users() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/admin/users") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + + let users = json["users"].as_array().unwrap(); + assert!(users.len() >= 4); // admin, reader, writer, limited + + // Verify admin user exists + let admin_user = users.iter().find(|u| u["username"] == "admin"); + assert!(admin_user.is_some()); +} + +#[test] +#[serial] +fn test_admin_create_user() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let new_user = serde_json::json!({ + "username": "newuser", + "password": "newpass", + "permissions": [] + }); + + let resp = client + .post("/admin/users") + .basic_auth("admin", Some("admin")) + .json(&new_user) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); + + // Verify user can authenticate + let resp = client + .get("/v2/") + .basic_auth("newuser", Some("newpass")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_admin_create_duplicate_user() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let duplicate_user = serde_json::json!({ + "username": "admin", + "password": "newpass", + "permissions": [] + }); + + let resp = client + .post("/admin/users") + .basic_auth("admin", Some("admin")) + .json(&duplicate_user) + .send() + .unwrap(); + + assert_eq!(resp.status(), 409); // Conflict +} + +#[test] +#[serial] +fn test_admin_delete_user() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Create user first + let new_user = serde_json::json!({ + "username": "todelete", + "password": "pass", + "permissions": [] + }); + + client + .post("/admin/users") + .basic_auth("admin", Some("admin")) + .json(&new_user) + .send() + .unwrap(); + + // Delete user + let resp = client + .delete("/admin/users/todelete") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + + // Verify user cannot authenticate + let resp = client + .get("/v2/") + .basic_auth("todelete", Some("pass")) + .send() + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[test] +#[serial] +fn test_admin_delete_nonexistent_user() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .delete("/admin/users/nonexistent") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_admin_add_permission() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Create user with no permissions + let new_user = serde_json::json!({ + "username": "testperm", + "password": "pass", + "permissions": [] + }); + + client + .post("/admin/users") + .basic_auth("admin", Some("admin")) + .json(&new_user) + .send() + .unwrap(); + + // User should not be able to push + let blob = sample_blob(); + let digest = sample_blob_digest(); + let resp = client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("testperm", Some("pass")) + .body(blob.clone()) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); + + // Add permission + let permission = serde_json::json!({ + "username": "testperm", + "repository": "test/*", + "tag": "*", + "actions": ["pull", "push"] + }); + + let resp = client + .post("/admin/permissions") + .basic_auth("admin", Some("admin")) + .json(&permission) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + + // User should now be able to push + let resp = client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("testperm", Some("pass")) + .body(blob) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); +} + +#[test] +#[serial] +fn test_admin_requires_admin_permission() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Non-admin users should not access admin endpoints + let resp = client + .get("/admin/users") + .basic_auth("reader", Some("reader")) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); + + let resp = client + .post("/admin/users") + .basic_auth("writer", Some("writer")) + .json(&serde_json::json!({"username": "test", "password": "test", "permissions": []})) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); + + // Admin should have access + let resp = client + .get("/admin/users") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_admin_api_requires_authentication() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // No auth should fail + let resp = client.get("/admin/users").send().unwrap(); + assert_eq!(resp.status(), 401); + + // Invalid credentials should fail + let resp = client + .get("/admin/users") + .basic_auth("invalid", Some("invalid")) + .send() + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[test] +#[serial] +fn test_admin_create_user_with_permissions() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let new_user = serde_json::json!({ + "username": "fulluser", + "password": "pass", + "permissions": [ + { + "repository": "myorg/*", + "tag": "*", + "actions": ["pull", "push"] + } + ] + }); + + let resp = client + .post("/admin/users") + .basic_auth("admin", Some("admin")) + .json(&new_user) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); + + // Verify permissions work + let blob = sample_blob(); + let digest = sample_blob_digest(); + let resp = client + .post(&format!("/v2/myorg/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("fulluser", Some("pass")) + .body(blob) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); +} + +#[test] +#[serial] +fn test_admin_user_persistence() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Create user + let new_user = serde_json::json!({ + "username": "persistent", + "password": "pass", + "permissions": [] + }); + + client + .post("/admin/users") + .basic_auth("admin", Some("admin")) + .json(&new_user) + .send() + .unwrap(); + + // Verify user exists in list + let resp = client + .get("/admin/users") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + let json: serde_json::Value = resp.json().unwrap(); + let users = json["users"].as_array().unwrap(); + let persistent_user = users.iter().find(|u| u["username"] == "persistent"); + assert!(persistent_user.is_some()); +} diff --git a/tests/auth_permissions.rs b/tests/auth_permissions.rs new file mode 100644 index 0000000..6ccbda1 --- /dev/null +++ b/tests/auth_permissions.rs @@ -0,0 +1,486 @@ +mod common; + +use common::*; +use serial_test::serial; + +#[test] +#[serial] +fn test_auth_malformed_basic_auth_header() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/") + .header("Authorization", "Basic invalid-base64!!!!") + .send() + .unwrap(); + + assert_eq!(resp.status(), 401); +} + +#[test] +#[serial] +fn test_auth_missing_credentials() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client.get("/v2/").send().unwrap(); + + assert_eq!(resp.status(), 401); + assert!(resp.headers().contains_key("www-authenticate")); + let www_auth = resp + .headers() + .get("www-authenticate") + .unwrap() + .to_str() + .unwrap(); + assert!(www_auth.contains("Basic realm=")); +} + +#[test] +#[serial] +fn test_auth_invalid_username() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/") + .basic_auth("nonexistent", Some("password")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 401); +} + +#[test] +#[serial] +fn test_auth_invalid_password() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/") + .basic_auth("admin", Some("wrongpassword")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 401); +} + +#[test] +#[serial] +fn test_permission_admin_wildcard_access() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Admin should be able to push to any repo + let blob = sample_blob(); + let digest = sample_blob_digest(); + + let resp = client + .post(&format!("/v2/any/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); +} + +#[test] +#[serial] +fn test_permission_reader_can_pull() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // First upload as admin + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Reader should be able to GET + let resp = client + .get(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("reader", Some("reader")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_permission_reader_cannot_push() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Reader should NOT be able to push + let resp = client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("reader", Some("reader")) + .body(blob) + .send() + .unwrap(); + + assert_eq!(resp.status(), 403); +} + +#[test] +#[serial] +fn test_permission_writer_can_push() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Writer should be able to push + let resp = client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("writer", Some("writer")) + .body(blob) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); +} + +#[test] +#[serial] +fn test_permission_writer_cannot_delete() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload as writer + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("writer", Some("writer")) + .body(blob) + .send() + .unwrap(); + + // Writer should NOT be able to delete + let resp = client + .delete(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("writer", Some("writer")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 403); +} + +#[test] +#[serial] +fn test_permission_repository_pattern_exact_match() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Limited user has access to myorg/myrepo only + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Should work for exact match + let resp = client + .post(&format!( + "/v2/myorg/myrepo/blobs/uploads/?digest={}", + digest + )) + .basic_auth("limited", Some("limited")) + .body(blob.clone()) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); + + // Should fail for different repo + let resp = client + .post(&format!("/v2/myorg/other/blobs/uploads/?digest={}", digest)) + .basic_auth("limited", Some("limited")) + .body(blob) + .send() + .unwrap(); + + assert_eq!(resp.status(), 403); +} + +#[test] +#[serial] +fn test_permission_repository_pattern_prefix_wildcard() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Reader has access to test/* repos + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Upload as admin first + client + .post(&format!("/v2/test/repo1/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.clone()) + .send() + .unwrap(); + + client + .post(&format!("/v2/test/repo2/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.clone()) + .send() + .unwrap(); + + // Reader should access both + let resp = client + .get(&format!("/v2/test/repo1/blobs/{}", digest)) + .basic_auth("reader", Some("reader")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client + .get(&format!("/v2/test/repo2/blobs/{}", digest)) + .basic_auth("reader", Some("reader")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // But not other orgs + client + .post(&format!("/v2/other/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.clone()) + .send() + .unwrap(); + + let resp = client + .get(&format!("/v2/other/repo/blobs/{}", digest)) + .basic_auth("reader", Some("reader")) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); +} + +#[test] +#[serial] +fn test_permission_tag_pattern_prefix_wildcard() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Limited user can only pull tags matching v* + let blob = sample_blob(); + let blob_digest = sample_blob_digest(); + client + .post(&format!( + "/v2/myorg/myrepo/blobs/uploads/?digest={}", + blob_digest + )) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + + // Upload manifests with different tags as admin + client + .put("/v2/myorg/myrepo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + client + .put("/v2/myorg/myrepo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Limited user should access v1.0 (matches v*) + let resp = client + .get("/v2/myorg/myrepo/manifests/v1.0") + .basic_auth("limited", Some("limited")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // But not latest (doesn't match v*) + let resp = client + .get("/v2/myorg/myrepo/manifests/latest") + .basic_auth("limited", Some("limited")) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); +} + +#[test] +#[serial] +fn test_permission_cross_repo_mount_requires_both_permissions() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob to source as writer + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/source/blobs/uploads/?digest={}", digest)) + .basic_auth("writer", Some("writer")) + .body(blob) + .send() + .unwrap(); + + // Writer can mount within test/* repos (has both pull on source and push on target) + let resp = client + .post(&format!( + "/v2/test/target/blobs/uploads/?mount={}&from=test/source", + digest + )) + .basic_auth("writer", Some("writer")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); +} + +#[test] +#[serial] +fn test_permission_unauthorized_vs_forbidden() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // No credentials = 401 Unauthorized + let resp = client.get("/v2/").send().unwrap(); + assert_eq!(resp.status(), 401); + + // Invalid credentials = 401 Unauthorized + let resp = client + .get("/v2/") + .basic_auth("invalid", Some("invalid")) + .send() + .unwrap(); + assert_eq!(resp.status(), 401); + + // Valid credentials but no permission = 403 Forbidden + let resp = client + .post("/v2/other/repo/blobs/uploads/") + .basic_auth("limited", Some("limited")) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); +} + +#[test] +#[serial] +fn test_permission_admin_can_delete() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Admin should be able to delete + let resp = client + .delete(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 202); +} + +#[test] +#[serial] +fn test_permission_action_enforcement_on_manifest_operations() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob as admin + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + + // Reader cannot push manifest + let resp = client + .put("/v2/test/repo/manifests/latest") + .basic_auth("reader", Some("reader")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); + + // Writer can push manifest + let resp = client + .put("/v2/test/repo/manifests/latest") + .basic_auth("writer", Some("writer")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); + + // Reader can pull manifest + let resp = client + .get("/v2/test/repo/manifests/latest") + .basic_auth("reader", Some("reader")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // Writer cannot delete manifest (no delete permission) + let resp = client + .delete("/v2/test/repo/manifests/latest") + .basic_auth("writer", Some("writer")) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); + + // Admin can delete manifest + let resp = client + .delete("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 202); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..7fb0eb3 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,311 @@ +use std::net::TcpListener; +use std::path::PathBuf; +use std::process::{Child, Command}; +use std::thread; +use std::time::Duration; +use tempfile::TempDir; + +#[allow(dead_code)] +pub struct TestServer { + pub base_url: String, + pub host: String, + pub port: u16, + pub temp_dir: TempDir, + pub users_file: PathBuf, + process: Option, +} + +impl TestServer { + pub fn new() -> Self { + Self::new_with_users(default_test_users()) + } + + pub fn new_with_users(users_json: serde_json::Value) -> Self { + // Find available port + let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind to random port"); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let host = format!("127.0.0.1:{}", port); + let base_url = format!("http://{}", host); + + // Create isolated temp directory + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let temp_path = temp_dir.path(); + + // Setup storage directories + std::fs::create_dir_all(temp_path.join("blobs")).unwrap(); + std::fs::create_dir_all(temp_path.join("manifests")).unwrap(); + std::fs::create_dir_all(temp_path.join("uploads")).unwrap(); + + // Create users.json + let users_file = temp_path.join("users.json"); + std::fs::write( + &users_file, + serde_json::to_string_pretty(&users_json).unwrap(), + ) + .expect("Failed to write users.json"); + + TestServer { + base_url, + host, + port, + temp_dir, + users_file, + process: None, + } + } + + pub fn start(&mut self) { + // Get the workspace root directory + let workspace_root = std::env::current_dir().expect("Failed to get current directory"); + + // Build if not already built + let build_status = Command::new("cargo") + .args(["build", "--bin", "grain"]) + .current_dir(&workspace_root) + .status() + .expect("Failed to build grain"); + + assert!(build_status.success(), "Failed to build grain binary"); + + // Path to the binary + let binary_path = workspace_root.join("target/debug/grain"); + assert!(binary_path.exists(), "grain binary not found at {:?}", binary_path); + + // Change to temp directory for storage + let temp_path = self.temp_dir.path(); + + // Start server process + let mut child = Command::new(binary_path) + .args([ + "--host", + &self.host, + "--users-file", + self.users_file.to_str().unwrap(), + ]) + .current_dir(temp_path) + .spawn() + .expect("Failed to start grain server"); + + // Wait for server to be ready + let client = reqwest::blocking::Client::new(); + let url = format!("{}/v2/", self.base_url); + + for _ in 0..50 { + thread::sleep(Duration::from_millis(100)); + + // Check if process is still running + if let Ok(Some(_)) = child.try_wait() { + panic!("Server process exited prematurely"); + } + + // Try to connect + if client + .get(&url) + .basic_auth("admin", Some("admin")) + .send() + .is_ok() + { + self.process = Some(child); + return; + } + } + + // Kill the process if startup failed + let _ = child.kill(); + panic!("Server failed to start within timeout"); + } + + pub fn stop(&mut self) { + if let Some(mut process) = self.process.take() { + let _ = process.kill(); + let _ = process.wait(); + } + } + + pub fn client(&self) -> TestClient { + TestClient { + base_url: self.base_url.clone(), + client: reqwest::blocking::Client::new(), + } + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.stop(); + } +} + +pub struct TestClient { + pub base_url: String, + client: reqwest::blocking::Client, +} + +#[allow(dead_code)] +impl TestClient { + pub fn get(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client.get(format!("{}{}", self.base_url, path)) + } + + pub fn head(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client.head(format!("{}{}", self.base_url, path)) + } + + pub fn post(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client.post(format!("{}{}", self.base_url, path)) + } + + pub fn put(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client.put(format!("{}{}", self.base_url, path)) + } + + pub fn patch(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client.patch(format!("{}{}", self.base_url, path)) + } + + pub fn delete(&self, path: &str) -> reqwest::blocking::RequestBuilder { + self.client.delete(format!("{}{}", self.base_url, path)) + } +} + +pub fn default_test_users() -> serde_json::Value { + serde_json::json!({ + "users": [ + { + "username": "admin", + "password": "admin", + "permissions": [ + { + "repository": "*", + "tag": "*", + "actions": ["pull", "push", "delete"] + } + ] + }, + { + "username": "reader", + "password": "reader", + "permissions": [ + { + "repository": "test/*", + "tag": "*", + "actions": ["pull"] + } + ] + }, + { + "username": "writer", + "password": "writer", + "permissions": [ + { + "repository": "test/*", + "tag": "*", + "actions": ["pull", "push"] + } + ] + }, + { + "username": "limited", + "password": "limited", + "permissions": [ + { + "repository": "myorg/myrepo", + "tag": "v*", + "actions": ["pull"] + } + ] + } + ] + }) +} + +pub fn sample_blob() -> Vec { + b"This is a test blob content".to_vec() +} + +pub fn sample_blob_digest() -> String { + format!("sha256:{}", sha256::digest("This is a test blob content")) +} + +pub fn sample_manifest() -> serde_json::Value { + let blob_digest = sample_blob_digest(); + serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "size": 27, + "digest": blob_digest + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "size": 27, + "digest": blob_digest + } + ] + }) +} + +pub fn sample_manifest_digest(manifest: &serde_json::Value) -> String { + let manifest_bytes = serde_json::to_vec(manifest).unwrap(); + format!("sha256:{}", sha256::digest(&manifest_bytes)) +} + +pub fn sample_image_index() -> serde_json::Value { + let manifest_digest = sample_manifest_digest(&sample_manifest()); + serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "size": 500, + "digest": manifest_digest, + "platform": { + "architecture": "amd64", + "os": "linux" + } + } + ] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_lifecycle() { + let mut server = TestServer::new(); + server.start(); + + let client = server.client(); + let resp = client + .get("/v2/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + + server.stop(); + } + + #[test] + fn test_sample_data() { + let blob = sample_blob(); + assert!(!blob.is_empty()); + + let digest = sample_blob_digest(); + assert!(digest.starts_with("sha256:")); + + let manifest = sample_manifest(); + assert_eq!(manifest["schemaVersion"], 2); + + let index = sample_image_index(); + assert_eq!(index["schemaVersion"], 2); + } +} diff --git a/tests/docker_client.rs b/tests/docker_client.rs new file mode 100644 index 0000000..9102234 --- /dev/null +++ b/tests/docker_client.rs @@ -0,0 +1,356 @@ +// Docker client integration tests +// These tests require Docker to be installed and running +// Enabled with --features docker-tests + +#![cfg(feature = "docker-tests")] + +mod common; + +use common::*; +use serial_test::serial; +use std::process::Command; + +fn docker_available() -> bool { + Command::new("docker") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn docker_login(registry: &str, username: &str, password: &str) -> bool { + let output = Command::new("docker") + .args(["login", registry, "-u", username, "-p", password]) + .output() + .expect("Failed to run docker login"); + + output.status.success() +} + +fn docker_logout(registry: &str) { + let _ = Command::new("docker").args(["logout", registry]).output(); +} + +fn docker_tag(source: &str, target: &str) -> bool { + let output = Command::new("docker") + .args(["tag", source, target]) + .output() + .expect("Failed to run docker tag"); + + output.status.success() +} + +fn docker_push(image: &str) -> bool { + let output = Command::new("docker") + .args(["push", image]) + .output() + .expect("Failed to run docker push"); + + output.status.success() +} + +fn docker_pull(image: &str) -> bool { + let output = Command::new("docker") + .args(["pull", image]) + .output() + .expect("Failed to run docker pull"); + + output.status.success() +} + +fn docker_rmi(image: &str) { + let _ = Command::new("docker").args(["rmi", "-f", image]).output(); +} + +#[test] +#[serial] +fn test_docker_login_valid_credentials() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + + assert!(docker_login(®istry, "admin", "admin")); + + docker_logout(®istry); +} + +#[test] +#[serial] +fn test_docker_login_invalid_credentials() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + + assert!(!docker_login(®istry, "invalid", "invalid")); +} + +#[test] +#[serial] +fn test_docker_push_pull_image() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + let image_name = format!("{}/test/alpine:latest", registry); + + // Login + assert!(docker_login(®istry, "admin", "admin")); + + // Pull a small base image + assert!(docker_pull("alpine:latest")); + + // Tag for local registry + assert!(docker_tag("alpine:latest", &image_name)); + + // Push to local registry + assert!(docker_push(&image_name)); + + // Remove local images + docker_rmi(&image_name); + docker_rmi("alpine:latest"); + + // Pull from local registry + assert!(docker_pull(&image_name)); + + // Cleanup + docker_rmi(&image_name); + docker_logout(®istry); +} + +#[test] +#[serial] +fn test_docker_push_requires_authentication() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + let image_name = format!("{}/test/alpine:latest", registry); + + // Don't login - push should fail + docker_pull("alpine:latest"); + docker_tag("alpine:latest", &image_name); + + assert!(!docker_push(&image_name)); + + // Cleanup + docker_rmi(&image_name); + docker_rmi("alpine:latest"); +} + +#[test] +#[serial] +fn test_docker_push_with_limited_permissions() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + let allowed_image = format!("{}/test/allowed:latest", registry); + let forbidden_image = format!("{}/forbidden/denied:latest", registry); + + // Login as writer (has access to test/*) + assert!(docker_login(®istry, "writer", "writer")); + + // Pull base image + assert!(docker_pull("alpine:latest")); + + // Push to allowed repo should succeed + docker_tag("alpine:latest", &allowed_image); + assert!(docker_push(&allowed_image)); + + // Push to forbidden repo should fail + docker_tag("alpine:latest", &forbidden_image); + assert!(!docker_push(&forbidden_image)); + + // Cleanup + docker_rmi(&allowed_image); + docker_rmi(&forbidden_image); + docker_rmi("alpine:latest"); + docker_logout(®istry); +} + +#[test] +#[serial] +fn test_docker_multi_layer_image() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + let image_name = format!("{}/test/nginx:latest", registry); + + // Login + assert!(docker_login(®istry, "admin", "admin")); + + // Pull multi-layer image + assert!(docker_pull("nginx:alpine")); + + // Tag for local registry + assert!(docker_tag("nginx:alpine", &image_name)); + + // Push to local registry + assert!(docker_push(&image_name)); + + // Remove local copy + docker_rmi(&image_name); + docker_rmi("nginx:alpine"); + + // Pull back from local registry + assert!(docker_pull(&image_name)); + + // Cleanup + docker_rmi(&image_name); + docker_logout(®istry); +} + +#[test] +#[serial] +fn test_docker_manifest_inspect() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + let image_name = format!("{}/test/alpine:latest", registry); + + // Login and push image + assert!(docker_login(®istry, "admin", "admin")); + assert!(docker_pull("alpine:latest")); + assert!(docker_tag("alpine:latest", &image_name)); + assert!(docker_push(&image_name)); + + // Inspect manifest + let output = Command::new("docker") + .args(["manifest", "inspect", &image_name]) + .output() + .expect("Failed to run docker manifest inspect"); + + assert!(output.status.success()); + let manifest_json = String::from_utf8_lossy(&output.stdout); + assert!(manifest_json.contains("schemaVersion")); + + // Cleanup + docker_rmi(&image_name); + docker_rmi("alpine:latest"); + docker_logout(®istry); +} + +#[test] +#[serial] +fn test_docker_concurrent_operations() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + + // Login + assert!(docker_login(®istry, "admin", "admin")); + + // Pull base images + assert!(docker_pull("alpine:latest")); + assert!(docker_pull("busybox:latest")); + + // Tag both for local registry + let alpine_name = format!("{}/test/alpine:latest", registry); + let busybox_name = format!("{}/test/busybox:latest", registry); + + assert!(docker_tag("alpine:latest", &alpine_name)); + assert!(docker_tag("busybox:latest", &busybox_name)); + + // Push both (simulates concurrent operations) + assert!(docker_push(&alpine_name)); + assert!(docker_push(&busybox_name)); + + // Verify both can be pulled + docker_rmi(&alpine_name); + docker_rmi(&busybox_name); + + assert!(docker_pull(&alpine_name)); + assert!(docker_pull(&busybox_name)); + + // Cleanup + docker_rmi(&alpine_name); + docker_rmi(&busybox_name); + docker_rmi("alpine:latest"); + docker_rmi("busybox:latest"); + docker_logout(®istry); +} + +#[test] +#[serial] +fn test_docker_reader_can_pull_only() { + if !docker_available() { + println!("Docker not available, skipping test"); + return; + } + + let mut server = TestServer::new(); + server.start(); + + let registry = format!("127.0.0.1:{}", server.port); + let image_name = format!("{}/test/alpine:latest", registry); + + // First push as admin + assert!(docker_login(®istry, "admin", "admin")); + assert!(docker_pull("alpine:latest")); + assert!(docker_tag("alpine:latest", &image_name)); + assert!(docker_push(&image_name)); + docker_logout(®istry); + + // Login as reader + assert!(docker_login(®istry, "reader", "reader")); + + // Should be able to pull + docker_rmi(&image_name); + assert!(docker_pull(&image_name)); + + // Should NOT be able to push + let new_tag = format!("{}/test/alpine:newtag", registry); + docker_tag(&image_name, &new_tag); + assert!(!docker_push(&new_tag)); + + // Cleanup + docker_rmi(&image_name); + docker_rmi(&new_tag); + docker_rmi("alpine:latest"); + docker_logout(®istry); +} diff --git a/tests/gc_operations.rs b/tests/gc_operations.rs new file mode 100644 index 0000000..1a95aba --- /dev/null +++ b/tests/gc_operations.rs @@ -0,0 +1,462 @@ +mod common; + +use common::*; +use serial_test::serial; + +#[test] +#[serial] +fn test_gc_identifies_unreferenced_blobs() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload orphaned blob (not referenced by any manifest) + let orphan_blob = b"orphaned blob content"; + let orphan_digest = format!("sha256:{}", sha256::digest(orphan_blob)); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + orphan_digest + )) + .basic_auth("admin", Some("admin")) + .body(orphan_blob.to_vec()) + .send() + .unwrap(); + + // Upload referenced blob with manifest + let referenced_blob = sample_blob(); + let referenced_digest = sample_blob_digest(); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + referenced_digest + )) + .basic_auth("admin", Some("admin")) + .body(referenced_blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Run GC with dry-run + let resp = client + .post("/admin/gc?dry_run=true&grace_period_hours=0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let result: serde_json::Value = resp.json().unwrap(); + + assert!(result["blobs_scanned"].as_u64().unwrap() >= 2); + assert!(result["blobs_to_delete"].as_u64().unwrap() >= 1); + + // Verify orphaned blob still exists (dry-run) + let resp = client + .head(&format!("/v2/test/repo/blobs/{}", orphan_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_gc_actual_deletion() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload orphaned blob + let orphan_blob = b"orphaned blob to delete"; + let orphan_digest = format!("sha256:{}", sha256::digest(orphan_blob)); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + orphan_digest + )) + .basic_auth("admin", Some("admin")) + .body(orphan_blob.to_vec()) + .send() + .unwrap(); + + // Run GC without dry-run + let resp = client + .post("/admin/gc?dry_run=false&grace_period_hours=0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let result: serde_json::Value = resp.json().unwrap(); + assert!(result["blobs_deleted"].as_u64().unwrap() >= 1); + assert!(result["bytes_freed"].as_u64().unwrap() > 0); + + // Verify orphaned blob is gone + let resp = client + .head(&format!("/v2/test/repo/blobs/{}", orphan_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_gc_grace_period_enforcement() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload recent orphaned blob + let recent_blob = b"recent orphaned blob"; + let recent_digest = format!("sha256:{}", sha256::digest(recent_blob)); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + recent_digest + )) + .basic_auth("admin", Some("admin")) + .body(recent_blob.to_vec()) + .send() + .unwrap(); + + // Run GC with 24-hour grace period (recent blob should be preserved) + let resp = client + .post("/admin/gc?dry_run=false&grace_period_hours=24") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + + // Verify recent blob still exists + let resp = client + .head(&format!("/v2/test/repo/blobs/{}", recent_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_gc_manifest_reference_extraction() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload config blob + let config_blob = b"config blob content"; + let config_digest = format!("sha256:{}", sha256::digest(config_blob)); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + config_digest + )) + .basic_auth("admin", Some("admin")) + .body(config_blob.to_vec()) + .send() + .unwrap(); + + // Upload layer blob + let layer_blob = b"layer blob content"; + let layer_digest = format!("sha256:{}", sha256::digest(layer_blob)); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + layer_digest + )) + .basic_auth("admin", Some("admin")) + .body(layer_blob.to_vec()) + .send() + .unwrap(); + + // Create manifest referencing both blobs + let manifest = serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "size": config_blob.len(), + "digest": config_digest + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "size": layer_blob.len(), + "digest": layer_digest + } + ] + }); + + client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Run GC + let resp = client + .post("/admin/gc?dry_run=false&grace_period_hours=0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + + // Verify both referenced blobs still exist + let resp = client + .head(&format!("/v2/test/repo/blobs/{}", config_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client + .head(&format!("/v2/test/repo/blobs/{}", layer_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_gc_image_index_traversal() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob for sub-manifest + let blob = sample_blob(); + let blob_digest = sample_blob_digest(); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + blob_digest + )) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Upload sub-manifest + let sub_manifest = sample_manifest(); + let sub_manifest_bytes = serde_json::to_vec(&sub_manifest).unwrap(); + let sub_manifest_digest = format!("sha256:{}", sha256::digest(&sub_manifest_bytes)); + + client + .put(&format!("/v2/test/repo/manifests/{}", sub_manifest_digest)) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&sub_manifest) + .send() + .unwrap(); + + // Upload image index referencing sub-manifest + let index = serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "size": sub_manifest_bytes.len(), + "digest": sub_manifest_digest, + "platform": { + "architecture": "amd64", + "os": "linux" + } + } + ] + }); + + client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.index.v1+json") + .json(&index) + .send() + .unwrap(); + + // Run GC + let resp = client + .post("/admin/gc?dry_run=false&grace_period_hours=0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + + // Verify blob referenced by sub-manifest still exists + let resp = client + .head(&format!("/v2/test/repo/blobs/{}", blob_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_gc_statistics_accuracy() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload 3 orphaned blobs + for i in 1..=3 { + let blob = format!("orphaned blob {}", i); + let digest = format!("sha256:{}", sha256::digest(blob.as_bytes())); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.into_bytes()) + .send() + .unwrap(); + } + + // Run GC + let resp = client + .post("/admin/gc?dry_run=false&grace_period_hours=0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let result: serde_json::Value = resp.json().unwrap(); + + assert_eq!(result["blobs_scanned"].as_u64().unwrap(), 3); + assert_eq!(result["blobs_deleted"].as_u64().unwrap(), 3); + assert!(result["bytes_freed"].as_u64().unwrap() > 0); +} + +#[test] +#[serial] +fn test_gc_requires_admin_permission() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Non-admin user should not be able to run GC + let resp = client + .post("/admin/gc?dry_run=true&grace_period_hours=0") + .basic_auth("reader", Some("reader")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 403); + + // Admin should be able to run GC + let resp = client + .post("/admin/gc?dry_run=true&grace_period_hours=0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_gc_preserves_shared_blobs_across_repos() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob to repo1 + let shared_blob = b"shared blob content"; + let shared_digest = format!("sha256:{}", sha256::digest(shared_blob)); + client + .post(&format!( + "/v2/repo1/test/blobs/uploads/?digest={}", + shared_digest + )) + .basic_auth("admin", Some("admin")) + .body(shared_blob.to_vec()) + .send() + .unwrap(); + + // Create manifest in repo1 + let manifest1 = serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "size": shared_blob.len(), + "digest": shared_digest + }, + "layers": [] + }); + + client + .put("/v2/repo1/test/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest1) + .send() + .unwrap(); + + // Mount same blob to repo2 + client + .post(&format!( + "/v2/repo2/test/blobs/uploads/?mount={}&from=repo1/test", + shared_digest + )) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + // Create manifest in repo2 referencing the same blob + let manifest2 = serde_json::json!({ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "size": shared_blob.len(), + "digest": shared_digest + }, + "layers": [] + }); + + client + .put("/v2/repo2/test/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest2) + .send() + .unwrap(); + + // Run GC + let resp = client + .post("/admin/gc?dry_run=false&grace_period_hours=0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + + // Verify blob still exists in both repos + let resp = client + .head(&format!("/v2/repo1/test/blobs/{}", shared_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client + .head(&format!("/v2/repo2/test/blobs/{}", shared_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} diff --git a/tests/health_metrics.rs b/tests/health_metrics.rs new file mode 100644 index 0000000..5422995 --- /dev/null +++ b/tests/health_metrics.rs @@ -0,0 +1,313 @@ +mod common; + +use common::*; +use serial_test::serial; + +#[test] +#[serial] +fn test_health_liveness_always_returns_200() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client.get("/health/live").send().unwrap(); + + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + assert_eq!(json["status"], "alive"); +} + +#[test] +#[serial] +fn test_health_readiness_when_ready() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client.get("/health/ready").send().unwrap(); + + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + assert_eq!(json["ready"], true); + assert_eq!(json["checks"]["storage_accessible"], true); + assert_eq!(json["checks"]["users_loaded"], true); +} + +#[test] +#[serial] +fn test_health_detailed_endpoint() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client.get("/health").send().unwrap(); + + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + + assert_eq!(json["status"], "healthy"); + assert!(json["version"].is_string()); + assert!(json["uptime_seconds"].is_number()); + + let storage = &json["storage"]; + assert_eq!(storage["accessible"], true); + assert_eq!(storage["writable"], true); + assert!(storage["blobs_path"].is_string()); + assert!(storage["manifests_path"].is_string()); +} + +#[test] +#[serial] +fn test_health_no_authentication_required() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Health endpoints should work without auth + let resp = client.get("/health").send().unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client.get("/health/live").send().unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client.get("/health/ready").send().unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_metrics_endpoint_prometheus_format() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client.get("/metrics").send().unwrap(); + + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "text/plain; version=0.0.4; charset=utf-8" + ); + + let body = resp.text().unwrap(); + + // Should contain prometheus metrics + assert!(body.contains("grain_http_requests_total")); + assert!(body.contains("grain_request_duration_seconds")); +} + +#[test] +#[serial] +fn test_metrics_no_authentication_required() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Metrics endpoint should work without auth (for Prometheus scraping) + let resp = client.get("/metrics").send().unwrap(); + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_metrics_counter_increments() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Get initial metrics + let resp = client.get("/metrics").send().unwrap(); + let initial_body = resp.text().unwrap(); + + // Make some requests + let blob = sample_blob(); + let digest = sample_blob_digest(); + + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.clone()) + .send() + .unwrap(); + + client + .get(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + // Get updated metrics + let resp = client.get("/metrics").send().unwrap(); + let updated_body = resp.text().unwrap(); + + // Verify metrics changed (counters should have incremented) + assert_ne!(initial_body, updated_body); + assert!(updated_body.contains("grain_blob_uploads_total")); + assert!(updated_body.contains("grain_blob_downloads_total")); +} + +#[test] +#[serial] +fn test_metrics_auth_failure_counter() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Make invalid auth request + let resp = client + .get("/v2/") + .basic_auth("invalid", Some("invalid")) + .send() + .unwrap(); + assert_eq!(resp.status(), 401); + + // Check metrics + let resp = client.get("/metrics").send().unwrap(); + let body = resp.text().unwrap(); + + assert!(body.contains("grain_auth_failures_total")); +} + +#[test] +#[serial] +fn test_metrics_permission_denial_counter() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Make request that will be denied due to permissions + let blob = sample_blob(); + let digest = sample_blob_digest(); + + let resp = client + .post(&format!( + "/v2/forbidden/repo/blobs/uploads/?digest={}", + digest + )) + .basic_auth("limited", Some("limited")) + .body(blob) + .send() + .unwrap(); + assert_eq!(resp.status(), 403); + + // Check metrics + let resp = client.get("/metrics").send().unwrap(); + let body = resp.text().unwrap(); + + assert!(body.contains("grain_permission_denials_total")); +} + +#[test] +#[serial] +fn test_metrics_manifest_upload_download_counters() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and manifest + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Download manifest + client + .get("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + // Check metrics + let resp = client.get("/metrics").send().unwrap(); + let body = resp.text().unwrap(); + + assert!(body.contains("grain_manifest_uploads_total")); + assert!(body.contains("grain_manifest_downloads_total")); +} + +#[test] +#[serial] +fn test_metrics_request_duration_histogram() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Make some requests + client + .get("/v2/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + // Check metrics + let resp = client.get("/metrics").send().unwrap(); + let body = resp.text().unwrap(); + + assert!(body.contains("grain_request_duration_seconds")); + assert!(body.contains("_bucket")); + assert!(body.contains("_sum")); + assert!(body.contains("_count")); +} + +#[test] +#[serial] +fn test_metrics_endpoint_normalization() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Check metrics contain normalized endpoint + let resp = client.get("/metrics").send().unwrap(); + let body = resp.text().unwrap(); + + // Endpoints should be normalized to avoid cardinality explosion + // Should see /v2/{name}/blobs/{digest} not the actual digest + assert!(body.contains("grain_http_requests_total")); +} + +#[test] +#[serial] +fn test_health_uptime_tracking() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Get initial uptime + let resp = client.get("/health").send().unwrap(); + let json1: serde_json::Value = resp.json().unwrap(); + let uptime1 = json1["uptime_seconds"].as_u64().unwrap(); + + // Wait a bit + std::thread::sleep(std::time::Duration::from_secs(2)); + + // Get updated uptime + let resp = client.get("/health").send().unwrap(); + let json2: serde_json::Value = resp.json().unwrap(); + let uptime2 = json2["uptime_seconds"].as_u64().unwrap(); + + // Uptime should have increased + assert!(uptime2 > uptime1); +} diff --git a/tests/oci_endpoints.rs b/tests/oci_endpoints.rs new file mode 100644 index 0000000..f224881 --- /dev/null +++ b/tests/oci_endpoints.rs @@ -0,0 +1,674 @@ +mod common; + +use common::*; +use serial_test::serial; + +#[test] +#[serial] +fn test_end1_version_check_authenticated() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + // Note: Docker-Distribution-API-Version header may not be present in current implementation + // This is acceptable for basic OCI compliance +} + +#[test] +#[serial] +fn test_end1_version_check_unauthenticated() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client.get("/v2/").send().unwrap(); + + assert_eq!(resp.status(), 401); + assert!(resp.headers().contains_key("www-authenticate")); +} + +#[test] +#[serial] +fn test_end1_version_check_invalid_credentials() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/") + .basic_auth("invalid", Some("invalid")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 401); +} + +#[test] +#[serial] +fn test_end2_blob_get_nonexistent() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/test/repo/blobs/sha256:nonexistent") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_end2_blob_head_nonexistent() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .head("/v2/test/repo/blobs/sha256:nonexistent") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_end4a_blob_upload_initiate() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .post("/v2/test/repo/blobs/uploads/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 202); + assert!(resp.headers().contains_key("location")); + assert!(resp.headers().contains_key("docker-upload-uuid")); +} + +#[test] +#[serial] +fn test_end4b_monolithic_blob_upload() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let digest = sample_blob_digest(); + + let resp = client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/octet-stream") + .body(blob) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); + assert!(resp.headers().contains_key("location")); + assert!(resp.headers().contains_key("docker-content-digest")); +} + +#[test] +#[serial] +fn test_end4b_monolithic_upload_digest_mismatch() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let wrong_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + let resp = client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + wrong_digest + )) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/octet-stream") + .body(blob) + .send() + .unwrap(); + + assert_eq!(resp.status(), 400); +} + +#[test] +#[serial] +fn test_end5_end6_chunked_upload_complete() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Initiate upload + let resp = client + .post("/v2/test/repo/blobs/uploads/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 202); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + + // PATCH: Upload chunk + let blob = sample_blob(); + let resp = client + .patch(location) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/octet-stream") + .body(blob.clone()) + .send() + .unwrap(); + + assert_eq!(resp.status(), 202); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + + // PUT: Complete upload + let digest = sample_blob_digest(); + let resp = client + .put(&format!("{}?digest={}", location, digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); +} + +#[test] +#[serial] +fn test_end6_complete_upload_with_digest_mismatch() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Initiate and upload chunk + let resp = client + .post("/v2/test/repo/blobs/uploads/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + + let blob = sample_blob(); + let resp = client + .patch(location) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + + // Try to complete with wrong digest + let wrong_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let resp = client + .put(&format!("{}?digest={}", location, wrong_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 400); +} + +#[test] +#[serial] +fn test_end7_manifest_upload() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // First upload the blob referenced in manifest + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Upload manifest + let manifest = sample_manifest(); + let resp = client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); + assert!(resp.headers().contains_key("location")); + assert!(resp.headers().contains_key("docker-content-digest")); +} + +#[test] +#[serial] +fn test_end7_manifest_upload_invalid_json() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .body("invalid json") + .send() + .unwrap(); + + assert_eq!(resp.status(), 400); +} + +#[test] +#[serial] +fn test_end7_manifest_upload_invalid_schema() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let invalid_manifest = serde_json::json!({ + "schemaVersion": 99, + "config": {} + }); + + let resp = client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&invalid_manifest) + .send() + .unwrap(); + + assert_eq!(resp.status(), 400); +} + +#[test] +#[serial] +fn test_end3_manifest_get_by_tag() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and manifest first + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + client + .put("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Get manifest by tag + let resp = client + .get("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + assert!(resp.headers().contains_key("docker-content-digest")); + assert!(resp.headers().contains_key("content-type")); +} + +#[test] +#[serial] +fn test_end3_manifest_get_by_digest() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and manifest + let blob = sample_blob(); + let blob_digest = sample_blob_digest(); + client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + blob_digest + )) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + let manifest_digest = sample_manifest_digest(&manifest); + client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Get by digest + let resp = client + .get(&format!("/v2/test/repo/manifests/{}", manifest_digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_end3_manifest_head() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and manifest + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + client + .put("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // HEAD request + let resp = client + .head("/v2/test/repo/manifests/latest") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + assert!(resp.headers().contains_key("docker-content-digest")); + assert!(resp.headers().contains_key("content-length")); +} + +#[test] +#[serial] +fn test_end3_manifest_get_nonexistent() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/test/repo/manifests/nonexistent") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_end8a_tag_list_empty() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let resp = client + .get("/v2/test/repo/tags/list") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + assert_eq!(json["name"], "test/repo"); + assert!(json["tags"].is_array()); +} + +#[test] +#[serial] +fn test_end8a_tag_list_with_tags() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and manifests with different tags + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + for tag in &["v1.0", "v2.0", "latest"] { + client + .put(&format!("/v2/test/repo/manifests/{}", tag)) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + } + + // List tags + let resp = client + .get("/v2/test/repo/tags/list") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + let tags = json["tags"].as_array().unwrap(); + assert_eq!(tags.len(), 3); +} + +#[test] +#[serial] +fn test_end8b_tag_list_pagination() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and multiple tagged manifests + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + for i in 1..=10 { + client + .put(&format!("/v2/test/repo/manifests/v{}", i)) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + } + + // Request with pagination + let resp = client + .get("/v2/test/repo/tags/list?n=5") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + let tags = json["tags"].as_array().unwrap(); + assert!(tags.len() <= 5); +} + +#[test] +#[serial] +fn test_end9_delete_manifest() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and manifest + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + client + .put("/v2/test/repo/manifests/deleteme") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Delete manifest + let resp = client + .delete("/v2/test/repo/manifests/deleteme") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 202); + + // Verify it's gone + let resp = client + .get("/v2/test/repo/manifests/deleteme") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_end10_delete_blob() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Delete blob + let resp = client + .delete(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 202); + + // Verify it's gone + let resp = client + .get(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_end11_cross_repo_blob_mount_success() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob to source repo + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/source/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Mount to target repo + let resp = client + .post(&format!( + "/v2/target/repo/blobs/uploads/?mount={}&from=source/repo", + digest + )) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); + assert!(resp.headers().contains_key("location")); + + // Verify blob exists in target repo + let resp = client + .head(&format!("/v2/target/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); +} + +#[test] +#[serial] +fn test_end11_cross_repo_mount_nonexistent_blob() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + // Try to mount nonexistent blob + let resp = client + .post(&format!( + "/v2/target/repo/blobs/uploads/?mount={}&from=source/repo", + digest + )) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + // Should fall back to regular upload initiation + assert_eq!(resp.status(), 202); +} diff --git a/tests/storage_operations.rs b/tests/storage_operations.rs new file mode 100644 index 0000000..058f9ea --- /dev/null +++ b/tests/storage_operations.rs @@ -0,0 +1,503 @@ +mod common; + +use common::*; +use serial_test::serial; + +#[test] +#[serial] +fn test_storage_blob_write_and_read() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Write blob + let resp = client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.clone()) + .send() + .unwrap(); + + assert_eq!(resp.status(), 201); + + // Read blob + let resp = client + .get(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let retrieved_blob = resp.bytes().unwrap(); + assert_eq!(retrieved_blob.as_ref(), blob.as_slice()); +} + +#[test] +#[serial] +fn test_storage_digest_validation_on_upload() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = b"test content"; + let correct_digest = format!("sha256:{}", sha256::digest(blob)); + let wrong_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + // Upload with correct digest should succeed + let resp = client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + correct_digest + )) + .basic_auth("admin", Some("admin")) + .body(blob.to_vec()) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); + + // Upload with wrong digest should fail + let resp = client + .post(&format!( + "/v2/test/repo/blobs/uploads/?digest={}", + wrong_digest + )) + .basic_auth("admin", Some("admin")) + .body(blob.to_vec()) + .send() + .unwrap(); + assert_eq!(resp.status(), 400); +} + +#[test] +#[serial] +fn test_storage_manifest_write_and_read() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob first + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Write manifest + let manifest = sample_manifest(); + let resp = client + .put("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); + + // Read manifest + let resp = client + .get("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + let retrieved: serde_json::Value = resp.json().unwrap(); + assert_eq!(retrieved["schemaVersion"], manifest["schemaVersion"]); +} + +#[test] +#[serial] +fn test_storage_upload_session_lifecycle() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Initiate upload session + let resp = client + .post("/v2/test/repo/blobs/uploads/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 202); + + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + let uuid = resp.headers().get("docker-upload-uuid").unwrap(); + assert!(!uuid.is_empty()); + + // Append chunk 1 + let chunk1 = b"first chunk"; + let resp = client + .patch(location) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/octet-stream") + .body(chunk1.to_vec()) + .send() + .unwrap(); + assert_eq!(resp.status(), 202); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + + // Append chunk 2 + let chunk2 = b" second chunk"; + let resp = client + .patch(location) + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/octet-stream") + .body(chunk2.to_vec()) + .send() + .unwrap(); + assert_eq!(resp.status(), 202); + let location = resp.headers().get("location").unwrap().to_str().unwrap(); + + // Complete upload + let combined: Vec = [chunk1.as_slice(), chunk2.as_slice()].concat(); + let digest = format!("sha256:{}", sha256::digest(&combined)); + let resp = client + .put(&format!("{}?digest={}", location, digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); + + // Verify blob exists + let resp = client + .get(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.bytes().unwrap().as_ref(), combined.as_slice()); +} + +#[test] +#[serial] +fn test_storage_path_sanitization() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Try directory traversal in repo name + let resp = client + .post(&format!( + "/v2/../../../etc/passwd/blobs/uploads/?digest={}", + digest + )) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Should either sanitize or reject, but not create files outside tmp/ + assert!(resp.status() == 400 || resp.status() == 201); + + // Verify no file created outside temp directory + let temp_path = server.temp_dir.path(); + let parent = temp_path.parent().unwrap(); + + // Check that no suspicious files were created + if parent.join("etc").exists() { + panic!("Directory traversal succeeded - security issue!"); + } +} + +#[test] +#[serial] +fn test_storage_cross_repo_blob_mount_creates_hardlink() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob to source repo + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/source/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.clone()) + .send() + .unwrap(); + + // Mount to target repo + let resp = client + .post(&format!( + "/v2/target/repo/blobs/uploads/?mount={}&from=source/repo", + digest + )) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); + + // Verify blob exists in both locations + let resp = client + .get(&format!("/v2/source/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + let resp = client + .get(&format!("/v2/target/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // Verify both return same content + let source_content = client + .get(&format!("/v2/source/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap() + .bytes() + .unwrap(); + + let target_content = client + .get(&format!("/v2/target/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap() + .bytes() + .unwrap(); + + assert_eq!(source_content, target_content); + assert_eq!(source_content.as_ref(), blob.as_slice()); +} + +#[test] +#[serial] +fn test_storage_concurrent_uploads_same_repo() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Initiate two upload sessions + let resp1 = client + .post("/v2/test/repo/blobs/uploads/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + let location1 = resp1 + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .to_string(); + + let resp2 = client + .post("/v2/test/repo/blobs/uploads/") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + let location2 = resp2 + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .to_string(); + + // Verify different upload UUIDs + assert_ne!(location1, location2); + + // Upload different blobs to each session + let blob1 = b"blob 1 content"; + let digest1 = format!("sha256:{}", sha256::digest(blob1)); + + let blob2 = b"blob 2 content different"; + let digest2 = format!("sha256:{}", sha256::digest(blob2)); + + // Complete first upload + let resp = client + .patch(&location1) + .basic_auth("admin", Some("admin")) + .body(blob1.to_vec()) + .send() + .unwrap(); + let location1 = resp.headers().get("location").unwrap().to_str().unwrap(); + + let resp = client + .put(&format!("{}?digest={}", location1, digest1)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); + + // Complete second upload + let resp = client + .patch(&location2) + .basic_auth("admin", Some("admin")) + .body(blob2.to_vec()) + .send() + .unwrap(); + let location2 = resp.headers().get("location").unwrap().to_str().unwrap(); + + let resp = client + .put(&format!("{}?digest={}", location2, digest2)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 201); + + // Verify both blobs exist and have correct content + let content1 = client + .get(&format!("/v2/test/repo/blobs/{}", digest1)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap() + .bytes() + .unwrap(); + assert_eq!(content1.as_ref(), blob1); + + let content2 = client + .get(&format!("/v2/test/repo/blobs/{}", digest2)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap() + .bytes() + .unwrap(); + assert_eq!(content2.as_ref(), blob2); +} + +#[test] +#[serial] +fn test_storage_delete_blob() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Upload blob + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + // Verify it exists + let resp = client + .get(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // Delete it + let resp = client + .delete(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 202); + + // Verify it's gone + let resp = client + .get(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_storage_delete_manifest() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + // Upload blob and manifest + let blob = sample_blob(); + let digest = sample_blob_digest(); + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob) + .send() + .unwrap(); + + let manifest = sample_manifest(); + client + .put("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .header("Content-Type", "application/vnd.oci.image.manifest.v1+json") + .json(&manifest) + .send() + .unwrap(); + + // Verify it exists + let resp = client + .get("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + + // Delete it + let resp = client + .delete("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 202); + + // Verify it's gone + let resp = client + .get("/v2/test/repo/manifests/v1.0") + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + assert_eq!(resp.status(), 404); +} + +#[test] +#[serial] +fn test_storage_blob_metadata() { + let mut server = TestServer::new(); + server.start(); + let client = server.client(); + + let blob = sample_blob(); + let digest = sample_blob_digest(); + + // Upload blob + client + .post(&format!("/v2/test/repo/blobs/uploads/?digest={}", digest)) + .basic_auth("admin", Some("admin")) + .body(blob.clone()) + .send() + .unwrap(); + + // HEAD request should return metadata + let resp = client + .head(&format!("/v2/test/repo/blobs/{}", digest)) + .basic_auth("admin", Some("admin")) + .send() + .unwrap(); + + assert_eq!(resp.status(), 200); + assert!(resp.headers().contains_key("content-length")); + assert!(resp.headers().contains_key("docker-content-digest")); + + let content_length = resp + .headers() + .get("content-length") + .unwrap() + .to_str() + .unwrap() + .parse::() + .unwrap(); + assert_eq!(content_length, blob.len()); +}