From a6cd95bfa4914634b7af780ebb84e4847826e76e Mon Sep 17 00:00:00 2001 From: Anthony Oteri Date: Tue, 26 Sep 2023 16:09:52 -0400 Subject: [PATCH] Add unit tests for existing commands --- Cargo.toml | 3 + src/commands.rs | 271 ++++++++++++++++++++++++++++++++++++++++++++++-- src/error.rs | 6 ++ src/main.rs | 21 ++-- 4 files changed, 288 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cc6a467..e786f3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,6 @@ thiserror = "1.0.48" toml = "0.8.0" url = { version = "2.4.1", features = ["serde"] } xdg = "2.5.2" + +[dev-dependencies] +mockito = "1.2.0" \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs index e7957aa..0206291 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -14,6 +14,8 @@ * limitations under the License. */ +use std::io::Write; + use serde::Deserialize; use url::Url; @@ -29,7 +31,7 @@ use crate::error::ApiError; /// /// Returns an `ApiError` if there is a problem fetching or parsing the /// responses from the Docker Registry API. -pub async fn catalog_handler(registry_url: &Url) -> Result<(), ApiError> { +pub async fn catalog_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> { #[derive(Deserialize)] struct Response { repositories: Vec, @@ -45,7 +47,7 @@ pub async fn catalog_handler(registry_url: &Url) -> Result<(), ApiError> { .collect(); for repository in repository_list { - println!("{repository}"); + writeln!(buf, "{repository}")?; } Ok(()) @@ -60,7 +62,11 @@ pub async fn catalog_handler(registry_url: &Url) -> Result<(), ApiError> { /// /// Returns an `ApiError` if there is a problem fetching or parsing the /// responses from the Docker Registry API. -pub async fn tags_handler(registry_url: &Url, name: &str) -> Result<(), ApiError> { +pub async fn tags_handler( + buf: &mut dyn Write, + registry_url: &Url, + name: &str, +) -> Result<(), ApiError> { #[derive(Deserialize)] struct Response { tags: Vec, @@ -76,7 +82,7 @@ pub async fn tags_handler(registry_url: &Url, name: &str) -> Result<(), ApiError .collect(); for tag in tag_list { - println!("{tag}"); + writeln!(buf, "{tag}")?; } Ok(()) @@ -89,7 +95,12 @@ pub async fn tags_handler(registry_url: &Url, name: &str) -> Result<(), ApiError /// Returns an `ApiError` if there is a problem fetching the manifest or if there /// is a problem parsing the response from the Docker Registry API. #[allow(clippy::unused_async)] -pub async fn show_handler(registry_url: &Url, image: &str, tag: &str) -> Result<(), ApiError> { +pub async fn show_handler( + _buf: &mut dyn Write, + registry_url: &Url, + image: &str, + tag: &str, +) -> Result<(), ApiError> { log::trace!("show_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); let path = format!("/v2/{image}/manifests/{tag}"); let _url = registry_url.join(&path)?; @@ -104,7 +115,12 @@ pub async fn show_handler(registry_url: &Url, image: &str, tag: &str) -> Result< /// manifest digest, or if there is a problem deleting the manifest from the /// Docker Registry API. #[allow(clippy::unused_async)] -pub async fn delete_handler(registry_url: &Url, image: &str, tag: &str) -> Result<(), ApiError> { +pub async fn delete_handler( + _buf: &mut dyn Write, + registry_url: &Url, + image: &str, + tag: &str, +) -> Result<(), ApiError> { log::trace!("delete_handler(registry_url: {registry_url:?}, image: {image}, tag: {tag})"); todo!() } @@ -117,7 +133,7 @@ pub async fn delete_handler(registry_url: &Url, image: &str, tag: &str) -> Resul /// /// Returns an `ApiError` if there is a problem communicating with the /// endpoint or if the required version is not supported. -pub async fn check_handler(registry_url: &Url) -> Result<(), ApiError> { +pub async fn check_handler(buf: &mut dyn Write, registry_url: &Url) -> Result<(), ApiError> { log::trace!("check_handler(registry_url: {registry_url:?})"); let path = "/v2"; @@ -125,5 +141,246 @@ pub async fn check_handler(registry_url: &Url) -> Result<(), ApiError> { let response = reqwest::get(url).await?; api::parse_response_status(&response)?; + writeln!(buf, "Ok")?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::error::Error; + + use url::Url; + + use crate::error; + + use super::*; + + /// Validate the happy path for the catalog handler. + /// + /// This test spins up a mock server, and makes a request to the catalog + /// endpoint. It checks that the handler both called the request the + /// expected number of times, and did not return an error. + #[async_std::test] + async fn test_catalog_handler() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/_catalog"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_body(r#"{"repositories": ["image1", "image2", "image3"]}"#) + .create(); + + let mut buf: Vec = Vec::new(); + let result = catalog_handler(&mut buf, ®istry_url).await; + assert!(result.is_ok()); + assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n"); + + mock_response.assert(); + } + + /// Validate the pagination of the catalog handler. + /// + /// This test spins up a mock server, and makes a request to the catalog + /// endpoint. The response includes a pagination link, which the handler + /// should follow, resulting in the combined list. It checks that the + /// handler both called the request the expected number of times, and did + /// not return an error. + #[async_std::test] + async fn test_catalog_handler_with_pagination() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/_catalog"; + let path2 = "/v2/_catalog?n=2,last=image2"; + + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header( + http::header::LINK.as_str(), + &format!(r#"<{path2}>; rel=next"#), + ) + .with_body(r#"{"repositories": ["image1", "image2"]}"#) + .create(); + + let mock_response2 = server + .mock("GET", path2) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_body(r#"{"repositories": ["image3"]}"#) + .create(); + + let mut buf: Vec = Vec::new(); + let result = catalog_handler(&mut buf, ®istry_url).await; + assert!(result.is_ok()); + assert_eq!(String::from_utf8(buf).unwrap(), *"image1\nimage2\nimage3\n"); + + mock_response.assert(); + mock_response2.assert(); + } + + /// Validate the happy path for the tags handler. + /// + /// This test spins up a mock server, and makes a request to the tags + /// endpoint. It checks that the handler both called the request the + /// expected number of times, and did not return an error. + #[async_std::test] + async fn test_tags_handler() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/some_image/tags/list"; + + // Mock the HTTP response for the Docker Registry API + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_body(r#"{"tags": ["tag1", "tag2", "tag3"]}"#) + .create(); + + let mut buf: Vec = Vec::new(); + let result = tags_handler(&mut buf, ®istry_url, "some_image").await; + assert!(result.is_ok()); + assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n"); + + mock_response.assert(); + } + + /// Validate the pagination of the catalog handler. + /// + /// This test spins up a mock server, and makes a request to the catalog + /// endpoint. The response includes a pagination link, which the handler + /// should follow, resulting in the combined list. It checks that the + /// handler both called the request the expected number of times, and did + /// not return an error. + #[async_std::test] + async fn test_tags_handler_with_pagination() { + let mut server = mockito::Server::new_async().await; + let path = "/v2/some_image/tags/list"; + let path2 = "/v2/some_image/tags/list?n=2,last=tag2"; + + // Mock the HTTP response for the Docker Registry API + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header( + http::header::LINK.as_str(), + &format!(r#"<{path2}>; rel=next"#), + ) + .with_body(r#"{"tags": ["tag1", "tag2"]}"#) + .create(); + + let mock_response2 = server + .mock("GET", path2) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_body(r#"{"tags": ["tag3"]}"#) + .create(); + + let mut buf: Vec = Vec::new(); + let result = tags_handler(&mut buf, ®istry_url, "some_image").await; + assert!(result.is_ok()); + assert_eq!(String::from_utf8(buf).unwrap(), *"tag1\ntag2\ntag3\n"); + + mock_response.assert(); + mock_response2.assert(); + } + + /// Validate the happy path for the check handler. + /// + /// This test spins up a mock server, and makes a request to the check + /// endpoint. It checks that the handler both called the request the + /// expected number of times, and did not return an error. + #[async_std::test] + async fn test_check_handler() { + let mut server = mockito::Server::new_async().await; + let path = "/v2"; + + // Mock the HTTP response for the Docker Registry API + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header("Docker-Distribution-API-Version", "registry/2.0") + .create(); + + let mut buf: Vec = Vec::new(); + let result = check_handler(&mut buf, ®istry_url).await; + assert!(result.is_ok()); + assert_eq!(String::from_utf8(buf).unwrap(), *"Ok\n"); + + mock_response.assert(); + } + + /// Validate the the check handler on invalid API version + /// + /// This validates that if the "Docker-Distribution-API-Version" header + /// is missing in the response, the appropriate error is returned. + #[async_std::test] + async fn test_check_handler_missing_api_version() -> Result<(), Box> { + let mut server = mockito::Server::new_async().await; + let path = "/v2"; + + // Mock the HTTP response for the Docker Registry API + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .create(); + + let mut buf: Vec = Vec::new(); + let result = check_handler(&mut buf, ®istry_url).await; + + // Ensure that we got the correct error type. + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + error::ApiError::UnexpectedResponse(_) => Ok(()), + e => Err(e), + }?; + + mock_response.assert(); + Ok(()) + } + + /// Validate the the check handler on invalid API version + /// + /// This validates that if the "Docker-Distribution-API-Version" header + /// is present in the response but contains an unexpected value, the + /// appropriate error is returned. + #[async_std::test] + async fn test_check_handler_invalid_api_version() -> Result<(), Box> { + let mut server = mockito::Server::new_async().await; + let path = "/v2"; + + // Mock the HTTP response for the Docker Registry API + let registry_url = Url::parse(&server.url()).expect("Failed to parse registry URL"); + let mock_response = server + .mock("GET", path) + .with_status(http::status::StatusCode::OK.as_u16().into()) + .with_header(http::header::CONTENT_TYPE.as_str(), "application/json") + .with_header("Docker-Distribution-API-Version", "registry/1.0") + .create(); + + let mut buf: Vec = Vec::new(); + let result = check_handler(&mut buf, ®istry_url).await; + + // Ensure that we got the correct error type. + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + error::ApiError::UnsupportedVersion(_) => Ok(()), + e => Err(e), + }?; + + mock_response.assert(); + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs index 50a9e8f..763b788 100644 --- a/src/error.rs +++ b/src/error.rs @@ -29,6 +29,9 @@ pub enum DredgeError { /// An error building the registry URL #[error("Error determining registry URL from {0}")] RegistryUrlError(String), + + #[error(transparent)] + IOError(#[from] std::io::Error), } /// An error related to the communication with the registry API. @@ -56,6 +59,9 @@ pub enum ApiError { #[error("Resource not found")] NotFound, + + #[error(transparent)] + IOError(#[from] std::io::Error), } impl From for ApiError { diff --git a/src/main.rs b/src/main.rs index 42e9914..3dcd7e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ #![deny(clippy::pedantic)] use clap::Parser; +use std::io::{self, Write}; use url::Url; use crate::cli::Cli; @@ -63,18 +64,26 @@ async fn main() -> Result<(), DredgeError> { let registry_url: Url = parse_registry_arg(&args.registry)?; // -- Dispatch control to the appropriate command handler. + let mut buf: Vec = Vec::new(); match args.command { - Commands::Catalog => commands::catalog_handler(®istry_url).await?, - Commands::Tags { name } => commands::tags_handler(®istry_url, &name).await?, + Commands::Catalog => commands::catalog_handler(&mut buf, ®istry_url).await?, + Commands::Tags { name } => commands::tags_handler(&mut buf, ®istry_url, &name).await?, Commands::Show { image, tag } => { - commands::show_handler(®istry_url, &image, &tag.unwrap_or(LATEST.to_string())) - .await?; + commands::show_handler( + &mut buf, + ®istry_url, + &image, + &tag.unwrap_or(LATEST.to_string()), + ) + .await?; } Commands::Delete { image, tag } => { - commands::delete_handler(®istry_url, &image, &tag).await?; + commands::delete_handler(&mut buf, ®istry_url, &image, &tag).await?; } - Commands::Check => commands::check_handler(®istry_url).await?, + Commands::Check => commands::check_handler(&mut buf, ®istry_url).await?, } + io::stdout().write_all(&buf)?; + Ok(()) }