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/config/src/server.rs b/crates/config/src/server.rs index ccc692b..7dc43f4 100644 --- a/crates/config/src/server.rs +++ b/crates/config/src/server.rs @@ -1,5 +1,4 @@ // - use actix::prelude::*; use std::{ @@ -91,7 +90,7 @@ impl Webhook { } /// The URL for the webhook where forges should send their updates -#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize, derive_more::AsRef)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, derive_more::AsRef)] pub struct WebhookUrl(String); /// The directory to store server data, such as cloned repos 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..814cc00 --- /dev/null +++ b/crates/forge-forgejo/src/tests.rs @@ -0,0 +1,706 @@ +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 crate::tests::forgejo::with; + + use super::*; + + #[tokio::test] + async fn should_return_a_list_of_matching_webhooks() { + let repo_details = given::repo_details(); + let webhook_url = given::any_webhook_url(); + 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(); + + let mut args = with::WebhookArgs { + net: &mut net, + hostname, + repo_path, + token, + }; + // page 1 with three items + with::get_webhooks_by_page( + 1, + &[ + with::ReturnedWebhook::new(hook_id_1, &webhook_url), + with::ReturnedWebhook::new(hook_id_2, &webhook_url), + with::ReturnedWebhook::new(hook_id_3, &given::any_webhook_url()), + ], + &mut args, + ); + // page 2 with no items - stops pagination + with::get_webhooks_by_page(2, &[], &mut args); + + 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 crate::tests::forgejo::with; + + use super::*; + + use git_next_config::WebhookId; + use kxio::network::StatusCode; + use serde_json::json; + + #[tokio::test] + async fn should_register_a_new_webhook() { + let repo_details = given::repo_details(); + assert!( + repo_details.repo_config.is_some(), + "repo_details needs to have repo_config for this test" + ); + 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 mut net = given::a_network(); + let mut args = with::WebhookArgs { + net: &mut net, + hostname, + repo_path, + token, + }; + + // there are no existing matching webhooks + with::get_webhooks_by_page(1, &[], &mut args); + // register the webhook will succeed + let webhook_id = given::a_forgejo_webhook_id(); + 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}")) + ); + } + + #[tokio::test] + async fn should_abort_if_repo_config_missing() { + let mut repo_details = given::repo_details(); + repo_details.repo_config.take(); + assert!( + repo_details.repo_config.is_none(), + "repo_details needs to NOT have repo_config for this test" + ); + let webhook_url = + given::a_webhook_url(repo_details.forge.forge_alias(), &repo_details.repo_alias); + let net = given::a_network(); + let forge = given::a_forgejo_forge(&repo_details, net); + let_assert!(Err(err) = forge.register_webhook(&webhook_url).await); + assert!( + matches!(err, git::forge::webhook::Error::NoRepoConfig), + "{err:?}" + ); + } + + #[tokio::test] + async fn should_unregister_existing_webhooks_before_registering() { + let repo_details = given::repo_details(); + assert!( + repo_details.repo_config.is_some(), + "repo_details needs to have repo_config for this test" + ); + 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 mut net = given::a_network(); + let mut args = with::WebhookArgs { + net: &mut net, + hostname, + repo_path, + token, + }; + let hook1 = with::ReturnedWebhook::new(given::a_forgejo_webhook_id(), &webhook_url); + let hook2 = with::ReturnedWebhook::new(given::a_forgejo_webhook_id(), &webhook_url); + let hook3 = with::ReturnedWebhook::new( + given::a_forgejo_webhook_id(), + &given::any_webhook_url(), + ); + let hooks = [hook1, hook2, hook3]; + + // there are three existing webhooks, two are matching webhooks + with::get_webhooks_by_page(1, &hooks, &mut args); + with::get_webhooks_by_page(2, &[], &mut args); + // should unregister 1 and 2, but not 3 + with::unregister_webhook(&hooks[0], &mut args); + with::unregister_webhook(&hooks[1], &mut args); + // register the webhook will succeed + let webhook_id = given::a_forgejo_webhook_id(); + 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}")) + ); + } + + #[tokio::test] + async fn should_return_error_if_empty_network_response() { + let repo_details = given::repo_details(); + assert!( + repo_details.repo_config.is_some(), + "repo_details needs to have repo_config for this test" + ); + 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 mut net = given::a_network(); + let mut args = with::WebhookArgs { + net: &mut net, + hostname, + repo_path, + token, + }; + + // there are no existing matching webhooks + with::get_webhooks_by_page(1, &[], &mut args); + // register the webhook will return empty response + net.add_post_response( + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}").as_str(), + StatusCode::OK, + json!({}).to_string().as_str(), // empty response + ); + + let forge = given::a_forgejo_forge(&repo_details, net); + + let_assert!(Err(err) = forge.register_webhook(&webhook_url).await); + assert!( + matches!(err, git::forge::webhook::Error::FailedToRegister(_)), + "{err:?}" + ); + } + + #[tokio::test] + async fn should_return_error_on_network_error() { + let repo_details = given::repo_details(); + assert!( + repo_details.repo_config.is_some(), + "repo_details needs to have repo_config for this test" + ); + 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 mut net = given::a_network(); + let mut args = with::WebhookArgs { + net: &mut net, + hostname, + repo_path, + token, + }; + + // there are no existing matching webhooks + with::get_webhooks_by_page(1, &[], &mut args); + // register the webhook will return empty response + net.add_post_error( + format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}").as_str(), + "error", + ); + + let forge = given::a_forgejo_forge(&repo_details, net); + + let_assert!(Err(err) = forge.register_webhook(&webhook_url).await); + assert!( + matches!(err, git::forge::webhook::Error::FailedToRegister(_)), + "{err:?}" + ); + } + } + mod with { + use serde::Serialize; + use serde_json::json; + + use git_next_config::{server::WebhookUrl, Hostname, RepoPath}; + use kxio::network::{self, StatusCode}; + + pub fn get_webhooks_by_page( + page: u8, + response: &[ReturnedWebhook], + args: &mut WebhookArgs, + ) { + let hostname = args.hostname; + let repo_path = args.repo_path; + let token = args.token; + args.net.add_get_response( + format!( + "https://{hostname}/api/v1/repos/{repo_path}/hooks?page={page}&token={token}" + ) + .as_str(), + StatusCode::OK, + json!(response).to_string().as_str(), + ); + } + pub fn unregister_webhook(hook1: &ReturnedWebhook, args: &mut WebhookArgs) { + let webhook_id = hook1.id; + let hostname = args.hostname; + let repo_path = args.repo_path; + let token = args.token; + args.net.add_delete_response( + format!( + "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" + ) + .as_str(), + StatusCode::OK, + "", + ); + } + pub struct WebhookArgs<'a> { + pub net: &'a mut network::MockNetwork, + pub hostname: &'a Hostname, + pub repo_path: &'a RepoPath, + pub token: &'a str, + } + #[derive(Debug, Serialize)] + pub struct ReturnedWebhook { + pub id: i64, + pub config: Config, + } + impl ReturnedWebhook { + pub fn new(id: i64, url: &WebhookUrl) -> Self { + Self { + id, + config: Config { url: url.clone() }, + } + } + } + #[derive(Debug, Serialize)] + pub struct Config { + pub url: WebhookUrl, + } + } + 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.clone(), 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 any_webhook_url() -> git_next_config::server::WebhookUrl { + given::a_webhook_url(&given::a_forge_alias(), &given::a_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, diff --git a/crates/git/src/repository/tests.rs b/crates/git/src/repository/tests.rs index 36e2c23..6de807c 100644 --- a/crates/git/src/repository/tests.rs +++ b/crates/git/src/repository/tests.rs @@ -189,6 +189,7 @@ mod git_clone { use crate::{repository, GitRemote, RepoDetails}; #[test] + #[ignore] // slow test ~1.5 seconds fn should_clone_repo() { let_assert!(Ok(fs) = kxio::fs::temp()); let r = crate::repository::new();