diff --git a/src/api.rs b/src/api.rs index 61a968e..41ea0ab 100644 --- a/src/api.rs +++ b/src/api.rs @@ -87,13 +87,9 @@ where } fn check_sig_header(secret_key: &[u8], sig_header: &[u8], body: &[u8]) -> Result<(), AppError> { - let sig_header_decoded = hex::decode(sig_header).map_err(|err| anyhow!(err))?; + let sig_header_decoded = hex::decode(sig_header).map_err(|_| AppError::WebHookSigHeaderInvalidErr)?; - let webhook_sig_header_secret = - std::env::var("WEBHOOK_SIG_HEADER_SECRET").map_err(|err| anyhow!(err))?; - - let mut mac = Hmac::::new_from_slice(&webhook_sig_header_secret.into_bytes()) - .map_err(|err| anyhow!(err))?; + let mut mac = Hmac::::new_from_slice(secret_key).map_err(|err| anyhow!(err))?; mac.update(body); @@ -105,3 +101,35 @@ fn check_sig_header(secret_key: &[u8], sig_header: &[u8], body: &[u8]) -> Result false => Err(AppError::WebHookSigHeaderInvalidErr), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn valid_json_bytes_parse_to_value() { + let body = serde_json::to_vec( + &json!({"action": "created", "pull_request": {"id": 1}, "comment": {"body": "hi"}}), + ) + .unwrap(); + let Json(value) = Json::::from_bytes(&body).unwrap(); + assert_eq!(value["action"], "created"); + assert_eq!(value["pull_request"]["id"], 1); + assert_eq!(value["comment"]["body"], "hi"); + } + + #[test] + fn malformed_json_bytes_return_malformed_error() { + let body = b"not valid json"; + let result = Json::::from_bytes(body); + assert!(result.is_err()); + } + + #[test] + fn empty_body_returns_malformed_error() { + let body = b""; + let result = Json::::from_bytes(body); + assert!(result.is_err()); + } +} diff --git a/src/errors.rs b/src/errors.rs index 704956f..16f4804 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -15,6 +15,12 @@ pub enum AppError { #[error("WebHook sig header is invalid")] WebHookSigHeaderInvalidErr, + #[error("Missing required field: {0}")] + MissingField(String), + + #[error("Wrong type for field: {0}")] + WrongFieldType(String), + #[error(transparent)] Other(#[from] anyhow::Error), } @@ -22,19 +28,39 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { - AppError::MalformedJsonErr => (StatusCode::BAD_REQUEST, "Malformed Json"), + AppError::MalformedJsonErr => { + (StatusCode::BAD_REQUEST, "Malformed Json".to_string()).into_response() + } AppError::BadJsonStructErr => ( StatusCode::BAD_REQUEST, - "Json not contains mandatory fields", - ), - AppError::WebHookSigHeaderNotFoundErr => { - (StatusCode::BAD_REQUEST, "WebHook sig header not found") - } - AppError::WebHookSigHeaderInvalidErr => { - (StatusCode::BAD_REQUEST, "WebHook sig header is invalid") - } - AppError::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"), + "Json not contains mandatory fields".to_string(), + ) + .into_response(), + AppError::WebHookSigHeaderNotFoundErr => ( + StatusCode::BAD_REQUEST, + "WebHook sig header not found".to_string(), + ) + .into_response(), + AppError::WebHookSigHeaderInvalidErr => ( + StatusCode::UNAUTHORIZED, + "WebHook sig header is invalid".to_string(), + ) + .into_response(), + AppError::MissingField(ref field) => ( + StatusCode::BAD_REQUEST, + format!("Missing required field: {}", field), + ) + .into_response(), + AppError::WrongFieldType(ref field) => ( + StatusCode::BAD_REQUEST, + format!("Wrong type for field: {}", field), + ) + .into_response(), + AppError::Other(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + ) + .into_response(), } - .into_response() } } diff --git a/src/gitea.rs b/src/gitea.rs index d148cc9..5d1d1b8 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,8 +1,8 @@ -use anyhow::anyhow; use serde_json::Value; use crate::errors::AppError; +#[derive(Debug, PartialEq)] pub enum WebhookType { Review(u64, String), } @@ -15,9 +15,9 @@ impl TryFrom for WebhookType { let comment = json.get("comment"); let action = json .get("action") - .ok_or(anyhow!("action not found"))? + .ok_or(AppError::MissingField("action".into()))? .as_str() - .ok_or(anyhow!("error while action"))?; + .ok_or(AppError::WrongFieldType("action".into()))?; if action != "created" { return Err(AppError::BadJsonStructErr); @@ -26,16 +26,16 @@ impl TryFrom for WebhookType { if let (Some(pull_request), Some(comment)) = (pull_request, comment) { let comment_body = comment .get("body") - .ok_or(anyhow!("comment body not found"))? + .ok_or(AppError::MissingField("comment.body".into()))? .as_str() - .ok_or(anyhow!("error while get pr comment"))? + .ok_or(AppError::WrongFieldType("comment.body".into()))? .to_string(); let pr_id = pull_request .get("id") - .ok_or(anyhow!("pr id not found"))? + .ok_or(AppError::MissingField("pull_request.id".into()))? .as_u64() - .ok_or(anyhow!("error while get pr id"))?; + .ok_or(AppError::WrongFieldType("pull_request.id".into()))?; return Ok(WebhookType::Review(pr_id, comment_body)); } @@ -43,3 +43,156 @@ impl TryFrom for WebhookType { Err(AppError::BadJsonStructErr) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn valid_webhook_parses_review() { + let payload = json!({ + "action": "created", + "pull_request": { "id": 42 }, + "comment": { "body": "LGTM" } + }); + let result = WebhookType::try_from(payload).unwrap(); + assert_eq!(result, WebhookType::Review(42, "LGTM".into())); + } + + #[test] + fn missing_action_returns_error() { + let payload = json!({ + "pull_request": { "id": 1 }, + "comment": { "body": "ok" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::MissingField(ref f) if f == "action")); + } + + #[test] + fn action_not_created_returns_bad_json_struct() { + let payload = json!({ + "action": "updated", + "pull_request": { "id": 1 }, + "comment": { "body": "ok" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::BadJsonStructErr)); + } + + #[test] + fn action_not_a_string_returns_error() { + let payload = json!({ + "action": 123, + "pull_request": { "id": 1 }, + "comment": { "body": "ok" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::WrongFieldType(ref f) if f == "action")); + } + + #[test] + fn missing_pull_request_returns_bad_json_struct() { + let payload = json!({ + "action": "created", + "comment": { "body": "ok" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::BadJsonStructErr)); + } + + #[test] + fn missing_comment_returns_bad_json_struct() { + let payload = json!({ + "action": "created", + "pull_request": { "id": 1 } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::BadJsonStructErr)); + } + + #[test] + fn missing_pr_id_returns_error() { + let payload = json!({ + "action": "created", + "pull_request": { "number": 1 }, + "comment": { "body": "ok" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::MissingField(ref f) if f == "pull_request.id")); + } + + #[test] + fn pr_id_not_a_number_returns_error() { + let payload = json!({ + "action": "created", + "pull_request": { "id": "not-a-number" }, + "comment": { "body": "ok" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::WrongFieldType(ref f) if f == "pull_request.id")); + } + + #[test] + fn missing_comment_body_returns_error() { + let payload = json!({ + "action": "created", + "pull_request": { "id": 1 }, + "comment": { "text": "no body" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::MissingField(ref f) if f == "comment.body")); + } + + #[test] + fn comment_body_not_a_string_returns_error() { + let payload = json!({ + "action": "created", + "pull_request": { "id": 1 }, + "comment": { "body": 999 } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::WrongFieldType(ref f) if f == "comment.body")); + } + + #[test] + fn null_pull_request_returns_error() { + let payload = json!({ + "action": "created", + "pull_request": null, + "comment": { "body": "ok" } + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::MissingField(ref f) if f == "pull_request.id")); + } + + #[test] + fn null_comment_returns_error() { + let payload = json!({ + "action": "created", + "pull_request": { "id": 1 }, + "comment": null + }); + let err = WebhookType::try_from(payload).unwrap_err(); + assert!(matches!(err, AppError::MissingField(ref f) if f == "comment.body")); + } + + #[test] + fn large_pr_id_parses_correctly() { + let payload = json!({ + "action": "created", + "pull_request": { "id": 18446744073709551615u64 }, + "comment": { "body": "max u64" } + }); + let result = WebhookType::try_from(payload).unwrap(); + assert_eq!(result, WebhookType::Review(18446744073709551615, "max u64".into())); + } + + #[test] + fn full_webhook_payload_parses() { + let payload: Value = serde_json::from_str(include_str!("../docs/webhook_pr_body.json")).unwrap(); + let result = WebhookType::try_from(payload).unwrap(); + assert_eq!(result, WebhookType::Review(1, "Test comment".into())); + } +}