// #![allow(clippy::expect_used)] // used with mock net use crate::ForgeJo; use git_next_core::{ git::{self, forge::commit::Status, ForgeLike as _}, server::{ListenUrl, RepoListenUrl}, BranchName, ForgeAlias, ForgeConfig, ForgeNotification, ForgeType, GitDir, Hostname, RepoAlias, RepoBranches, RepoPath, ServerRepoConfig, StoragePathType, WebhookAuth, WebhookId, }; use assert2::let_assert; use kxio::net::{MockNet, StatusCode}; use secrecy::ExposeSecret; use serde::Serialize; use serde_json::json; use std::collections::BTreeMap; mod forgejo { use super::*; #[test] fn should_return_name() { let fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), given::a_network()); assert_eq!(forge.name(), "forgejo"); } mod is_message_authorised { use super::*; #[test] fn should_return_true_with_valid_header() { let fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), 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 fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), 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 fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), 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 fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), 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 fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), 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 super::*; #[test] fn should_parse_valid_body() { let fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), 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 = git_next_core::webhook::forge_notification::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(git_next_core::webhook::push::Branch::Next) ); } #[test] fn should_error_invalid_body() { let fs = given::a_filesystem(); let forge = given::a_forgejo_forge(&given::repo_details(&fs), given::a_network()); let body = git_next_core::webhook::forge_notification::Body::new( r#"{"type":"invalid"}"#.to_string(), ); let_assert!(Err(_) = forge.parse_webhook_body(&body)); } } mod commit_status { use super::*; #[tokio::test] async fn should_return_pass_for_success() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let commit = given::a_commit(); let net = given::a_network(); given::a_commit_state("success", &net, &repo_details, &commit); let forge = given::a_forgejo_forge(&repo_details, net); assert_eq!( forge.commit_status(&commit).await.expect("status"), Status::Pass ); } #[tokio::test] async fn should_return_pending_for_pending() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let commit = given::a_commit(); let net = given::a_network(); given::a_commit_state("pending", &net, &repo_details, &commit); let forge = given::a_forgejo_forge(&repo_details, net); assert_eq!( forge.commit_status(&commit).await.expect("status"), Status::Pending ); } #[tokio::test] async fn should_return_fail_for_failure() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let commit = given::a_commit(); let net = given::a_network(); given::a_commit_state("failure", &net, &repo_details, &commit); let forge = given::a_forgejo_forge(&repo_details, net); assert_eq!( forge.commit_status(&commit).await.expect("status"), Status::Fail ); } #[tokio::test] async fn should_return_fail_for_error() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let commit = given::a_commit(); let net = given::a_network(); given::a_commit_state("error", &net, &repo_details, &commit); let forge = given::a_forgejo_forge(&repo_details, net); assert_eq!( forge.commit_status(&commit).await.expect("status"), Status::Fail ); } #[tokio::test] async fn should_return_pending_for_blank() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let commit = given::a_commit(); let net = given::a_network(); given::a_commit_state("", &net, &repo_details, &commit); let forge = given::a_forgejo_forge(&repo_details, net); assert_eq!( forge.commit_status(&commit).await.expect("status"), Status::Pending ); } #[tokio::test] async fn should_return_pending_for_no_statuses() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let commit = given::a_commit(); let net = given::a_network(); net.on() .get(given::a_commit_status_url(&repo_details, &commit)) .respond(StatusCode::OK) .body( json!({ "state": "" // blank => Pending }) .to_string(), ) .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); assert_eq!( forge.commit_status(&commit).await.expect("status"), Status::Pending ); } #[tokio::test] async fn should_return_pending_for_network_error() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let commit = given::a_commit(); let mock_net = given::a_network(); mock_net .on() .get(given::a_commit_status_url(&repo_details, &commit)) .respond(StatusCode::INTERNAL_SERVER_ERROR) .body("book today") .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, mock_net); assert_eq!( forge.commit_status(&commit).await.expect("status"), Status::Pending ); } } mod list_webhooks { use super::*; #[tokio::test] async fn should_return_a_list_of_matching_webhooks() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let repo_listen_url = given::a_repo_listen_url(&repo_details); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; 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, &repo_listen_url), with::ReturnedWebhook::new(hook_id_2, &repo_listen_url), with::ReturnedWebhook::new(hook_id_3, &given::a_repo_listen_url(&repo_details)), ], &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(&repo_listen_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 fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let repo_listen_url = given::a_repo_listen_url(&repo_details); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; let token = repo_details.forge.token().expose_secret(); let net = given::a_network(); net.on() .get(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks?page=1&token={token}" )) .respond(StatusCode::INTERNAL_SERVER_ERROR) .body("error_message") .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Err(_) = forge.list_webhooks(&repo_listen_url).await); } } mod unregister_webhook { use super::*; #[tokio::test] async fn should_delete_webhook() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; let token = repo_details.forge.token().expose_secret(); let webhook_id = given::a_webhook_id(); let net = given::a_network(); net.on() .delete(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" )) .respond(StatusCode::OK) .body("") .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Ok(()) = forge.unregister_webhook(&webhook_id).await); } #[tokio::test] async fn should_return_error_on_network_error() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; let token = repo_details.forge.token().expose_secret(); let webhook_id = given::a_webhook_id(); let net = given::a_network(); net.on() .delete(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" )) .respond(StatusCode::INTERNAL_SERVER_ERROR) .body("error") .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Err(err) = forge.unregister_webhook(&webhook_id).await); assert!( matches!(err, git::forge::webhook::Error::FailedToUnregister(_)), "{err:?}" ); } } mod register_webhook { use super::*; #[tokio::test] async fn should_register_a_new_webhook() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); assert!( repo_details.repo_config.is_some(), "repo_details needs to have repo_config for this test" ); let repo_listen_url = given::a_repo_listen_url(&repo_details); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; 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.on() .post(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}" )) .respond(StatusCode::OK) .body(json!({"id": webhook_id, "config":{}}).to_string()) .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Ok(registered_webhook) = forge.register_webhook(&repo_listen_url).await); assert_eq!( registered_webhook.id(), &WebhookId::new(format!("{webhook_id}")) ); } #[tokio::test] async fn should_abort_if_repo_config_missing() { let fs = given::a_filesystem(); let mut repo_details = given::repo_details(&fs); repo_details.repo_config.take(); assert!( repo_details.repo_config.is_none(), "repo_details needs to NOT have repo_config for this test" ); let repo_listen_url = given::a_repo_listen_url(&repo_details); let net = given::a_network(); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Err(err) = forge.register_webhook(&repo_listen_url).await); assert!( matches!(err, git::forge::webhook::Error::NoRepoConfig), "{err:?}" ); } #[tokio::test] async fn should_unregister_existing_webhooks_before_registering() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); assert!( repo_details.repo_config.is_some(), "repo_details needs to have repo_config for this test" ); let repo_listen_url = given::a_repo_listen_url(&repo_details); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; 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 hook_1 = with::ReturnedWebhook::new(given::a_forgejo_webhook_id(), &repo_listen_url); let hook_2 = with::ReturnedWebhook::new(given::a_forgejo_webhook_id(), &repo_listen_url); let hook_3 = with::ReturnedWebhook::new( given::a_forgejo_webhook_id(), &given::a_repo_listen_url(&repo_details), ); let hooks = [hook_1, hook_2, hook_3]; // 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.on() .post(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}" )) .respond(StatusCode::OK) .body(json!({"id": webhook_id, "config":{}}).to_string()) .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Ok(registered_webhook) = forge.register_webhook(&repo_listen_url).await); assert_eq!( registered_webhook.id(), &WebhookId::new(format!("{webhook_id}")) ); } #[tokio::test] async fn should_return_error_if_empty_network_response() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); assert!( repo_details.repo_config.is_some(), "repo_details needs to have repo_config for this test" ); let repo_listen_url = given::a_repo_listen_url(&repo_details); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; 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.on() .post(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}" )) .respond(StatusCode::OK) .body(json!({}).to_string()) // empty response) .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Err(err) = forge.register_webhook(&repo_listen_url).await); assert!( matches!(err, git::forge::webhook::Error::NetworkResponseEmpty), "{err:?}" ); } #[tokio::test] async fn should_return_error_on_network_error() { let fs = given::a_filesystem(); let repo_details = given::repo_details(&fs); assert!( repo_details.repo_config.is_some(), "repo_details needs to have repo_config for this test" ); let repo_listen_url = given::a_repo_listen_url(&repo_details); let hostname = repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; 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.on() .post(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}" )) .respond(StatusCode::INTERNAL_SERVER_ERROR) .body("error") .expect("mock"); let forge = given::a_forgejo_forge(&repo_details, net); let_assert!(Err(err) = forge.register_webhook(&repo_listen_url).await); assert!( matches!(err, git::forge::webhook::Error::FailedToRegister(_)), "{err:?}" ); } } 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; let token = args.token; args.net .on() .get(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks?page={page}&token={token}" )) .respond(StatusCode::OK) .body(json!(response).to_string()) .expect("mock"); } 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 .on() .delete(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" )) .respond(StatusCode::OK) .body("") .expect("mock"); } pub struct WebhookArgs<'a> { pub net: &'a MockNet, 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: &RepoListenUrl) -> Self { Self { id, config: Config { url: url.to_string(), }, } } } #[derive(Debug, Serialize)] pub struct Config { pub url: String, } } mod given { use git::RepoDetails; use git_next_core::server::RepoListenUrl; use kxio::net::{MockNet, StatusCode}; use super::*; pub fn a_commit_state( state: impl AsRef, net: &MockNet, repo_details: &git::RepoDetails, commit: &git::Commit, ) { net.on() .get(a_commit_status_url(repo_details, commit)) .respond(StatusCode::OK) .body((json!({"state":state.as_ref()})).to_string()) .expect("mock"); } 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; let token = repo_details.forge.token().expose_secret(); format!( "https://{hostname}/api/v1/repos/{repo_path}/commits/{commit}/status?token={token}" ) } pub enum Header { Valid(WebhookAuth), Missing, NonBasic, NonUlid, WrongUlid, } pub fn a_webhook_message(header: Header) -> ForgeNotification { ForgeNotification::new( given::a_forge_alias(), given::a_repo_alias(), given::webhook_headers(header), given::a_webhook_message_body(), ) } pub fn webhook_headers(header: Header) -> BTreeMap { let mut headers = BTreeMap::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_forgejo_forge( repo_details: &git::RepoDetails, net: impl Into, ) -> ForgeJo { let net: kxio::net::Net = net.into(); ForgeJo::new(repo_details.clone(), net) } pub fn a_forgejo_webhook_id() -> i64 { use rand::RngCore as _; rand::thread_rng().next_u32().into() } pub fn a_webhook_auth() -> WebhookAuth { WebhookAuth::generate() } pub fn a_webhook_message_body() -> git_next_core::webhook::forge_notification::Body { git_next_core::webhook::forge_notification::Body::new(a_name()) } pub fn repo_branches() -> RepoBranches { RepoBranches::new(a_name(), a_name(), a_name()) } 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::net::MockNet { kxio::net::mock() } 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 maybe_a_number() -> Option { use rand::Rng; let mut rng = rand::thread_rng(); if Rng::gen_ratio(&mut rng, 1, 2) { Some(a_number()) } else { None } } pub fn a_number() -> u32 { use rand::Rng; let mut rng = rand::thread_rng(); rng.gen_range(0..100) } pub fn a_webhook_id() -> WebhookId { WebhookId::new(a_name()) } pub fn a_branch_name() -> BranchName { BranchName::new(a_name()) } pub fn a_git_dir(fs: &kxio::fs::FileSystem) -> GitDir { let dir_name = a_name(); let dir = fs.base().join(dir_name); GitDir::new(dir, StoragePathType::Internal) } pub fn a_forge_config() -> ForgeConfig { ForgeConfig::new( ForgeType::MockForge, a_name(), a_name(), a_name(), maybe_a_number(), BTreeMap::default(), // no repos ) } pub fn a_server_repo_config() -> ServerRepoConfig { let main = a_branch_name().peel(); let next = a_branch_name().peel(); let dev = a_branch_name().peel(); ServerRepoConfig::new( format!("{}/{}", a_name(), a_name()), main.clone(), None, Some(main), Some(next), Some(dev), ) } pub fn a_commit() -> git::Commit { git::Commit::new(a_commit_sha(), a_commit_message()) } pub fn a_commit_message() -> git::commit::Message { git::commit::Message::new(a_name()) } pub fn a_commit_sha() -> git::commit::Sha { git::commit::Sha::new(a_name()) } pub fn a_filesystem() -> kxio::fs::TempFileSystem { kxio::fs::temp().expect("temp fs") } pub fn repo_details(fs: &kxio::fs::FileSystem) -> git::RepoDetails { let generation = git::Generation::default(); let repo_alias = a_repo_alias(); let server_repo_config = a_server_repo_config(); let forge_alias = a_forge_alias(); let forge_config = a_forge_config(); let gitdir = a_git_dir(fs); git::RepoDetails::new( generation, &repo_alias, &server_repo_config, &forge_alias, &forge_config, gitdir, ) } pub fn a_repo_listen_url(repo_details: &RepoDetails) -> RepoListenUrl { let listen_url = a_listen_url(); let forge_alias = repo_details.forge.forge_alias().clone(); let repo_alias = repo_details.repo_alias.clone(); RepoListenUrl::new((listen_url, forge_alias, repo_alias)) } fn a_listen_url() -> ListenUrl { ListenUrl::new(a_name()) } } }