diff --git a/Cargo.toml b/Cargo.toml index f35c90a..d4c2a11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,3 +94,4 @@ tokio = { version = "1.37" } # Testing assert2 = "0.3" pretty_assertions = "1.4" +rand = "0.8" diff --git a/crates/forge-forgejo/Cargo.toml b/crates/forge-forgejo/Cargo.toml index f5c551e..dfb0aa3 100644 --- a/crates/forge-forgejo/Cargo.toml +++ b/crates/forge-forgejo/Cargo.toml @@ -51,6 +51,7 @@ tokio = { workspace = true } [dev-dependencies] # Testing assert2 = { workspace = true } +rand = { workspace = true } [lints.clippy] nursery = { level = "warn", priority = -1 } diff --git a/crates/forge-forgejo/src/lib.rs b/crates/forge-forgejo/src/lib.rs index 8a25a6d..c59f57c 100644 --- a/crates/forge-forgejo/src/lib.rs +++ b/crates/forge-forgejo/src/lib.rs @@ -1,4 +1,7 @@ // +#[cfg(test)] +mod tests; + mod webhook; use git::forge::commit::Status; diff --git a/crates/forge-forgejo/src/tests.rs b/crates/forge-forgejo/src/tests.rs new file mode 100644 index 0000000..a2e7727 --- /dev/null +++ b/crates/forge-forgejo/src/tests.rs @@ -0,0 +1,494 @@ +use git_next_config as config; +use git_next_git as git; + +mod forgejo { + use super::*; + + use assert2::let_assert; + use std::collections::BTreeMap; + + use crate::ForgeJo; + use config::{ + webhook::message::Body, ForgeAlias, ForgeConfig, ForgeType, GitDir, RepoAlias, + RepoBranches, ServerRepoConfig, WebhookAuth, WebhookMessage, + }; + use git::ForgeLike as _; + + #[test] + fn should_return_name() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + assert_eq!(forge.name(), "forgejo"); + } + + mod is_message_authorised { + + use super::*; + + #[test] + fn should_return_true_with_valid_header() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + let auth = given::a_webhook_auth(); + let message = given::a_webhook_message(given::Header::Valid(auth.clone())); + assert!(forge.is_message_authorised(&message, &auth)); + } + #[test] + fn should_return_false_with_missing_header() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + let auth = given::a_webhook_auth(); + let message = given::a_webhook_message(given::Header::Missing); + assert!(!forge.is_message_authorised(&message, &auth)); + } + #[test] + fn should_return_false_with_non_basic_prefix() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + let auth = given::a_webhook_auth(); + let message = given::a_webhook_message(given::Header::NonBasic); + assert!(!forge.is_message_authorised(&message, &auth)); + } + #[test] + fn should_return_false_with_non_ulid_value() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + let auth = given::a_webhook_auth(); + let message = given::a_webhook_message(given::Header::NonUlid); + assert!(!forge.is_message_authorised(&message, &auth)); + } + #[test] + fn should_return_false_with_wrong_ulid_value() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + let auth = given::a_webhook_auth(); + let message = given::a_webhook_message(given::Header::WrongUlid); + assert!(!forge.is_message_authorised(&message, &auth)); + } + } + + mod parse_webhook_body { + + use serde_json::json; + + use super::*; + + #[test] + fn should_parse_valid_body() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + let repo_branches = given::repo_branches(); + let next = repo_branches.next(); + let sha = given::a_name(); + let message = given::a_name(); + let body = Body::new( + json!({"ref":format!("refs/heads/{next}"),"after":sha,"head_commit":{"message":message}}) + .to_string(), + ); + let_assert!(Ok(push) = forge.parse_webhook_body(&body)); + assert_eq!(push.sha(), sha); + assert_eq!(push.message(), message); + assert_eq!( + push.branch(&repo_branches), + Some(config::webhook::push::Branch::Next) + ); + } + + #[test] + fn should_error_invalid_body() { + let forge = given::a_forgejo_forge(given::repo_details(), given::a_network()); + let body = Body::new(r#"{"type":"invalid"}"#.to_string()); + let_assert!(Err(_) = forge.parse_webhook_body(&body)); + } + } + + mod commit_status { + use git_next_git::forge::commit::Status; + use kxio::network::StatusCode; + + use super::*; + + #[tokio::test] + async fn should_return_pass_for_success() { + let repo_details = given::repo_details(); + let commit = given::a_commit(); + let mut net = given::a_network(); + given::a_commit_state("success", &mut net, &repo_details, &commit); + let forge = given::a_forgejo_forge(repo_details, net); + assert_eq!(forge.commit_status(&commit).await, Status::Pass); + } + #[tokio::test] + async fn should_return_pending_for_pending() { + let repo_details = given::repo_details(); + let commit = given::a_commit(); + let mut net = given::a_network(); + given::a_commit_state("pending", &mut net, &repo_details, &commit); + let forge = given::a_forgejo_forge(repo_details, net); + assert_eq!(forge.commit_status(&commit).await, Status::Pending); + } + #[tokio::test] + async fn should_return_fail_for_failure() { + let repo_details = given::repo_details(); + let commit = given::a_commit(); + let mut net = given::a_network(); + given::a_commit_state("failure", &mut net, &repo_details, &commit); + let forge = given::a_forgejo_forge(repo_details, net); + assert_eq!(forge.commit_status(&commit).await, Status::Fail); + } + #[tokio::test] + async fn should_return_fail_for_error() { + let repo_details = given::repo_details(); + let commit = given::a_commit(); + let mut net = given::a_network(); + given::a_commit_state("error", &mut net, &repo_details, &commit); + let forge = given::a_forgejo_forge(repo_details, net); + assert_eq!(forge.commit_status(&commit).await, Status::Fail); + } + #[tokio::test] + async fn should_return_pending_for_blank() { + let repo_details = given::repo_details(); + let commit = given::a_commit(); + let mut net = given::a_network(); + given::a_commit_state("", &mut net, &repo_details, &commit); + let forge = given::a_forgejo_forge(repo_details, net); + assert_eq!(forge.commit_status(&commit).await, Status::Pending); + } + #[tokio::test] + async fn should_return_pending_for_no_statuses() { + let repo_details = given::repo_details(); + let commit = given::a_commit(); + let mut net = given::a_network(); + net.add_get_response( + &given::a_commit_status_url(&repo_details, &commit), + StatusCode::OK, + "", + ); + let forge = given::a_forgejo_forge(repo_details, net); + assert_eq!(forge.commit_status(&commit).await, Status::Pending); + } + #[tokio::test] + async fn should_return_pending_for_network_error() { + let repo_details = given::repo_details(); + let commit = given::a_commit(); + let mut net = given::a_network(); + net.add_get_error( + &given::a_commit_status_url(&repo_details, &commit), + "boom today", + ); + let forge = given::a_forgejo_forge(given::repo_details(), net); + assert_eq!(forge.commit_status(&commit).await, Status::Pending); + } + } + + mod list_webhooks { + use git_next_config::WebhookId; + use git_next_git::ForgeLike as _; + use kxio::network::StatusCode; + use serde_json::json; + + use super::*; + + #[tokio::test] + async fn should_return_a_list_of_matching_webhooks() { + let repo_details = given::repo_details(); + let webhook_url = given::a_webhook_url(&given::a_forge_alias(), &given::a_repo_alias()); + let hostname = repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + use secrecy::ExposeSecret; + let token = repo_details.forge.token().expose_secret(); + let hook_id_1 = given::a_forgejo_webhook_id(); + let hook_id_2 = given::a_forgejo_webhook_id(); + let hook_id_3 = given::a_forgejo_webhook_id(); + let mut net = given::a_network(); + + // page 1 with three items + net.add_get_response( + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?page=1&token={token}") + .as_str(), + StatusCode::OK, + json!([ + {"id":hook_id_1,"config":{"url":webhook_url.as_ref()}}, + {"id":hook_id_2,"config":{"url":webhook_url.as_ref()}}, + {"id":hook_id_3,"config":{"url":"other_url"}} + ]) + .to_string() + .as_str(), + ); + // page 2 with no items - stops pagination + net.add_get_response( + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?page=2&token={token}") + .as_str(), + StatusCode::OK, + json!([]).to_string().as_str(), + ); + + let forge = given::a_forgejo_forge(repo_details, net); + + let_assert!(Ok(result) = forge.list_webhooks(&webhook_url).await); + assert_eq!( + result, + vec![ + WebhookId::new(format!("{hook_id_1}")), + WebhookId::new(format!("{hook_id_2}")) + ] + ); + } + + #[tokio::test] + async fn should_return_any_network_error() { + let repo_details = given::repo_details(); + let webhook_url = given::a_webhook_url(&given::a_forge_alias(), &given::a_repo_alias()); + let hostname = repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + use secrecy::ExposeSecret; + let token = repo_details.forge.token().expose_secret(); + let mut net = given::a_network(); + + net.add_get_error( + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?page=1&token={token}") + .as_str(), + "error_message", + ); + + let forge = given::a_forgejo_forge(repo_details, net); + + let_assert!(Err(_) = forge.list_webhooks(&webhook_url).await); + } + } + + mod unregister_webhook { + use super::*; + use git_next_git::ForgeLike; + use kxio::network::StatusCode; + + #[tokio::test] + async fn should_delete_webhook() { + let repo_details = given::repo_details(); + let hostname = repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + use secrecy::ExposeSecret; + let token = repo_details.forge.token().expose_secret(); + let webhook_id = given::a_webhook_id(); + let mut net = given::a_network(); + + net.add_delete_response( + format!( + "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" + ) + .as_str(), + StatusCode::OK, + "", + ); + + let forge = given::a_forgejo_forge(repo_details, net); + + let_assert!(Ok(_) = forge.unregister_webhook(&webhook_id).await); + } + } + + mod register_webhook { + use git_next_config::WebhookId; + use kxio::network::StatusCode; + use serde_json::json; + + use super::*; + + #[tokio::test] + async fn should_register_a_new_webhook() { + let repo_details = given::repo_details(); + assert!( + repo_details.repo_config.is_some(), + "repo config not present" + ); + let webhook_url = + given::a_webhook_url(repo_details.forge.forge_alias(), &repo_details.repo_alias); + let hostname = repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + use secrecy::ExposeSecret; + let token = repo_details.forge.token().expose_secret(); + let webhook_id = given::a_forgejo_webhook_id(); + let mut net = given::a_network(); + + // there are no existing matching webhooks + net.add_get_response( + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?page=1&token={token}") + .as_str(), + StatusCode::OK, + json!([]).to_string().as_str(), + ); + // register the webhook + net.add_post_response( + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}").as_str(), + StatusCode::OK, + json!({"id": webhook_id, "config":{}}).to_string().as_str(), + ); + + let forge = given::a_forgejo_forge(repo_details, net); + + let_assert!(Ok(registered_webhook) = forge.register_webhook(&webhook_url).await); + assert_eq!( + registered_webhook.id(), + &WebhookId::new(format!("{webhook_id}")) + ); + } + + // should abort if there is no repo config + // should unregister existing webhooks before registering + // should return error if empty network response + // should return error on network error + } + mod given { + use std::collections::HashMap; + + use git_next_config::{server::Webhook, WebhookId}; + use kxio::network::{MockNetwork, StatusCode}; + use rand::RngCore; + use serde_json::json; + + use super::*; + + pub fn a_commit_state( + state: impl AsRef, + net: &mut MockNetwork, + repo_details: &git::RepoDetails, + commit: &git::Commit, + ) { + let response = json!({"state":state.as_ref()}); + net.add_get_response( + a_commit_status_url(repo_details, commit).as_str(), + StatusCode::OK, + response.to_string().as_str(), + ) + } + + pub fn a_commit_status_url( + repo_details: &git::RepoDetails, + commit: &git::Commit, + ) -> String { + let hostname = repo_details.forge.hostname(); + let repo_path = &repo_details.repo_path; + use secrecy::ExposeSecret; + let token = repo_details.forge.token().expose_secret(); + format!( + "https://{hostname}/api/v1/repos/{repo_path}/commits/{commit}/status?token={token}" + ) + } + + pub fn a_webhook_auth() -> WebhookAuth { + WebhookAuth::generate() + } + + pub enum Header { + Valid(WebhookAuth), + Missing, + NonBasic, + NonUlid, + WrongUlid, + } + pub fn a_webhook_message(header: Header) -> WebhookMessage { + WebhookMessage::new( + given::a_forge_alias(), + given::a_repo_alias(), + given::webhook_headers(header), + given::a_webhook_message_body(), + ) + } + pub fn webhook_headers(header: Header) -> HashMap { + let mut headers = HashMap::new(); + match header { + Header::Valid(auth) => { + headers.insert("authorization".to_string(), format!("Basic {auth}")); + } + Header::Missing => { /* don't add any header */ } + Header::NonBasic => { + headers.insert("authorization".to_string(), "Non-Basic".to_string()); + } + Header::NonUlid => { + headers.insert("authorization".to_string(), "Basic 123456".to_string()); + } + Header::WrongUlid => { + headers.insert( + "authorization".to_string(), + format!("Basic {}", WebhookAuth::generate()), + ); + } + } + headers + } + pub fn a_webhook_message_body() -> Body { + Body::new(a_name()) + } + pub fn a_commit() -> git::Commit { + git::Commit::new( + git::commit::Sha::new(a_name()), + git::commit::Message::new(a_name()), + ) + } + + pub fn repo_branches() -> RepoBranches { + RepoBranches::new(a_name(), a_name(), a_name()) + } + + pub fn a_forgejo_forge( + repo_details: git::RepoDetails, + net: impl Into, + ) -> ForgeJo { + ForgeJo::new(repo_details, net.into()) + } + pub fn repo_details() -> git::RepoDetails { + git::RepoDetails::new( + git::Generation::new(), + &a_repo_alias(), + &ServerRepoConfig::new( + format!("{}/{}", a_name(), a_name()), // repo path: owner/repo + a_name(), + None, + Some(a_name()), + Some(a_name()), + Some(a_name()), + ), + &a_forge_alias(), + &ForgeConfig::new( + ForgeType::ForgeJo, + a_name(), + a_name(), + a_name(), + BTreeMap::default(), + ), + GitDir::default(), + ) + } + + pub fn a_forge_alias() -> ForgeAlias { + ForgeAlias::new(a_name()) + } + + pub fn a_repo_alias() -> RepoAlias { + RepoAlias::new(a_name()) + } + pub fn a_network() -> kxio::network::MockNetwork { + kxio::network::MockNetwork::new() + } + + pub fn a_webhook_url( + forge_alias: &ForgeAlias, + repo_alias: &RepoAlias, + ) -> git_next_config::server::WebhookUrl { + Webhook::new(a_name()).url(forge_alias, repo_alias) + } + + pub fn a_name() -> String { + use rand::Rng; + use std::iter; + + fn generate(len: usize) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + let one_char = || CHARSET[rng.gen_range(0..CHARSET.len())] as char; + iter::repeat_with(one_char).take(len).collect() + } + generate(5) + } + + pub fn a_webhook_id() -> WebhookId { + WebhookId::new(given::a_name()) + } + + pub fn a_forgejo_webhook_id() -> i64 { + rand::thread_rng().next_u32().into() + } + } +} diff --git a/crates/git/src/forge/commit.rs b/crates/git/src/forge/commit.rs index dafab24..ace9830 100644 --- a/crates/git/src/forge/commit.rs +++ b/crates/git/src/forge/commit.rs @@ -1,4 +1,4 @@ -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub enum Status { Pass, Fail,