// use crate::{Github, GithubState, GithubStatus}; use git_next_core::{ git::{self, forge::commit::Status, ForgeLike}, server::{InboundWebhook, WebhookUrl}, webhook::{self, forge_notification::Body}, ForgeAlias, ForgeConfig, ForgeNotification, ForgeType, GitDir, Hostname, RepoAlias, RepoBranches, RepoPath, ServerRepoConfig, StoragePathType, WebhookAuth, WebhookId, }; use assert2::let_assert; use kxio::network::{self, MockNetwork, StatusCode}; use rand::RngCore; use serde::Serialize; use serde_json::json; use std::{collections::BTreeMap, path::PathBuf}; mod github { use super::*; #[test] fn should_return_name() { let forge = given::a_github_forge(&given::repo_details(), given::a_network()); assert_eq!(forge.name(), "github"); } mod is_message_authorised { use super::*; #[test] fn should_return_true_with_valid_header() { let forge = given::a_github_forge(&given::repo_details(), given::a_network()); let auth = given::a_webhook_auth(); let message = given::a_webhook_message(given::Header::Valid( auth.clone(), given::a_webhook_message_body(), )); assert!(forge.is_message_authorised(&message, &auth)); } #[test] fn should_return_false_with_missing_header() { let forge = given::a_github_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_invalid_header() { let forge = given::a_github_forge(&given::repo_details(), given::a_network()); let auth = given::a_webhook_auth(); let message = given::a_webhook_message(given::Header::Invalid); assert!(!forge.is_message_authorised(&message, &auth)); } } mod parse_webhook_body { use super::*; #[test] fn should_parse_valid_body() { let forge = given::a_github_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(webhook::push::Branch::Next) ); } #[test] fn should_error_invalid_body() { let forge = given::a_github_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 super::*; macro_rules! status_tests{ ($($name:ident: $value:expr, )*) => { $( #[tokio::test] async fn $name() { let (states, expected) = $value; let repo_details = given::repo_details(); let commit = given::a_commit(); let mut net = given::a_network(); given::commit_states(&states, &mut net, &repo_details, &commit); let forge = given::a_github_forge(&repo_details, net); assert_eq!(forge.commit_status(&commit).await, expected); } )* } } status_tests!( pass_when_success: ([GithubState::Success], Status::Pass), pending_when_pending: ([GithubState::Pending], Status::Pending), fail_when_failure: ([GithubState::Failure], Status::Fail), fail_when_error: ([GithubState::Error], Status::Fail), pending_when_blank: ([GithubState::Blank], Status::Pending), pass_wneh_no_checks: ([], Status::Pass), pass_when_all_success: ([GithubState::Success, GithubState::Success], Status::Pass), fail_when_only_alpha_fails: ([GithubState::Failure, GithubState::Success], Status::Fail), fail_when_only_beta_fails: ([GithubState::Success, GithubState::Failure], Status::Fail), pending_when_all_pending: ([GithubState::Pending, GithubState::Pending], Status::Pending), pending_when_only_alpha_pending: ([GithubState::Pending, GithubState::Success], Status::Pending), pending_when_only_beta_pending: ([GithubState::Success, GithubState::Pending], 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_github_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_github_forge(&repo_details, net); assert_eq!(forge.commit_status(&commit).await, Status::Pending); } } mod list_webhooks { 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; let hook_id_1 = given::a_github_webhook_id(); let hook_id_2 = given::a_github_webhook_id(); let hook_id_3 = given::a_github_webhook_id(); let mut net = given::a_network(); let mut args = with::WebhookArgs { net: &mut net, hostname, repo_path, }; // 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_github_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; let mut net = given::a_network(); net.add_get_error( format!("https://api.{hostname}/repos/{repo_path}/hooks?page=1").as_str(), "error_message", ); let forge = given::a_github_forge(&repo_details, net); let_assert!(Err(_) = forge.list_webhooks(&webhook_url).await); } } mod unregister_webhook { use super::*; #[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; let webhook_id = given::a_webhook_id(); let mut net = given::a_network(); net.add_delete_response( format!("https://api.{hostname}/repos/{repo_path}/hooks/{webhook_id}").as_str(), StatusCode::OK, "", ); let forge = given::a_github_forge(&repo_details, net); let_assert!(Ok(_) = forge.unregister_webhook(&webhook_id).await); } #[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 hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; let mut net = given::a_network(); // unregister the webhook will return empty response net.add_delete_error( format!("https://api.{hostname}/repos/{repo_path}/hooks").as_str(), "error", ); let forge = given::a_github_forge(&repo_details, net); let webhook_id = given::a_webhook_id(); let_assert!(Err(err) = forge.unregister_webhook(&webhook_id).await); assert!( matches!(err, git::forge::webhook::Error::FailedToRegister(_)), "{err:?}" ); } } mod register_webhook { 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_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; let mut net = given::a_network(); let mut args = with::WebhookArgs { net: &mut net, hostname, repo_path, }; // there are no existing matching webhooks with::get_webhooks_by_page(1, &[], &mut args); // register the webhook will succeed let webhook_id = given::a_github_webhook_id(); net.add_post_response( format!("https://api.{hostname}/repos/{repo_path}/hooks").as_str(), StatusCode::OK, json!({"id": webhook_id, "config":{"url": webhook_url}}) .to_string() .as_str(), ); let forge = given::a_github_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_github_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; let mut net = given::a_network(); let mut args = with::WebhookArgs { net: &mut net, hostname, repo_path, }; let hook1 = with::ReturnedWebhook::new(given::a_github_webhook_id(), &webhook_url); let hook2 = with::ReturnedWebhook::new(given::a_github_webhook_id(), &webhook_url); let hook3 = with::ReturnedWebhook::new(given::a_github_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_github_webhook_id(); net.add_post_response( format!("https://api.{hostname}/repos/{repo_path}/hooks").as_str(), StatusCode::OK, json!({"id": webhook_id, "config":{"url": webhook_url}}) .to_string() .as_str(), ); let forge = given::a_github_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; let mut net = given::a_network(); let mut args = with::WebhookArgs { net: &mut net, hostname, repo_path, }; // 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://api.{hostname}/repos/{repo_path}/hooks").as_str(), StatusCode::OK, json!({}).to_string().as_str(), // empty response ); let forge = given::a_github_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; let mut net = given::a_network(); let mut args = with::WebhookArgs { net: &mut net, hostname, repo_path, }; // 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://api.{hostname}/repos/{repo_path}/hooks").as_str(), "error", ); let forge = given::a_github_forge(&repo_details, net); let_assert!(Err(err) = forge.register_webhook(&webhook_url).await); assert!( matches!(err, git::forge::webhook::Error::FailedToRegister(_)), "{err:?}" ); } } pub mod with { use super::*; pub fn get_webhooks_by_page( page: u8, response: &[ReturnedWebhook], args: &mut WebhookArgs, ) { let hostname = args.hostname; let repo_path = args.repo_path; args.net.add_get_response( format!("https://api.{hostname}/repos/{repo_path}/hooks?page={page}").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; args.net.add_delete_response( format!("https://api.{hostname}/repos/{repo_path}/hooks/{webhook_id}").as_str(), StatusCode::OK, "", ); } pub struct WebhookArgs<'a> { pub net: &'a mut network::MockNetwork, pub hostname: &'a Hostname, pub repo_path: &'a RepoPath, } #[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 super::*; pub fn commit_states( states: &[GithubState], net: &mut MockNetwork, repo_details: &git::RepoDetails, commit: &git::Commit, ) { let response = json!(states .iter() .map(|state| GithubStatus { state: state.to_owned() }) .collect::>()); 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; format!("https://api.{hostname}/repos/{repo_path}/commits/{commit}/statuses") } pub fn a_webhook_auth() -> WebhookAuth { WebhookAuth::generate() } pub enum Header { Valid(WebhookAuth, Body), Missing, Invalid, } pub fn a_webhook_message(header: Header) -> ForgeNotification { let body = match &header { Header::Valid(_, body) => body.clone(), _ => given::a_webhook_message_body(), }; ForgeNotification::new( given::a_forge_alias(), given::a_repo_alias(), given::webhook_headers(header), body, ) } pub fn webhook_headers(header: Header) -> BTreeMap { let mut headers = BTreeMap::new(); match header { Header::Valid(auth, body) => { if let Some(sig) = crate::webhook::sign_body(&auth, &body) { headers.insert("x-hub-signature-256".to_string(), format!("sha256={sig}")); } } Header::Missing => { /* don't add any header */ } Header::Invalid => { headers.insert( "x-hub-signature-256".to_string(), format!("{}", 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_github_forge( repo_details: &git::RepoDetails, net: impl Into, ) -> Github { Github::new(repo_details.clone(), net.into()) } pub fn repo_details() -> git::RepoDetails { git::RepoDetails::new( git::Generation::default(), &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::new(PathBuf::default(), StoragePathType::External), ) } 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) -> WebhookUrl { InboundWebhook::new(a_name()).url(forge_alias, repo_alias) } pub fn any_webhook_url() -> 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_github_webhook_id() -> i64 { rand::thread_rng().next_u32().into() } } }