From e33187dc805ec004a777b7a0696bfb08593af8c5 Mon Sep 17 00:00:00 2001 From: qpismont Date: Mon, 18 May 2026 22:40:28 +0200 Subject: [PATCH 1/7] impl webhook route --- src/api.rs | 4 ++-- src/env.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api.rs b/src/api.rs index 8df3989..d77a738 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4,7 +4,7 @@ use axum::routing::get; use crate::env; pub async fn start_api(config: env::EnvConfig) -> anyhow::Result<()> { - let app = Router::new().route("/", get(root)); + let app = Router::new().route("/webhook", get(webhook)); let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.http_port)).await?; axum::serve(listerner, app) @@ -12,6 +12,6 @@ pub async fn start_api(config: env::EnvConfig) -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!(e)) } -async fn root() -> &'static str { +async fn webhook() -> &'static str { "Hello, World!" } diff --git a/src/env.rs b/src/env.rs index 76a67c5..16002d7 100644 --- a/src/env.rs +++ b/src/env.rs @@ -1,5 +1,6 @@ use dotenvy::dotenv; + pub struct EnvConfig { pub http_port: u16, pub bot_name: String, @@ -9,7 +10,6 @@ pub fn load_config() -> anyhow::Result { dotenv().ok(); let http_port = std::env::var("HTTP_PORT")?.parse()?; - let bot_name = std::env::var("BOT_NAME")?; Ok(EnvConfig { -- 2.52.0 From c119bed142b771eb66d9087e36fbbb1ab9924f08 Mon Sep 17 00:00:00 2001 From: qpismont Date: Thu, 28 May 2026 21:35:41 +0000 Subject: [PATCH 2/7] parse gitea pr webhook --- .devcontainer/devcontainer.json | 12 +- docs/webhook_pr_body.json | 224 ++++++++++++++++++++++++++++++ src/api.rs | 51 ++++++- src/bot.rs | 5 + src/gitea.rs | 35 +++++ src/main.rs | 2 + src/{claude.rs => open_router.rs} | 0 7 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 docs/webhook_pr_body.json create mode 100644 src/bot.rs rename src/{claude.rs => open_router.rs} (100%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 139c737..745ef73 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,8 +12,18 @@ "containerEnv": { "SHELL": "/bin/bash" }, + "customizations": { + "vscode": { + "extensions": ["rust-lang.rust-analyzer"], + "settings": { + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer", + "editor.formatOnSave": true + } + } + } + }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", "workspaceFolder": "/workspaces/herald", - "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/docs/webhook_pr_body.json b/docs/webhook_pr_body.json new file mode 100644 index 0000000..1a7fec3 --- /dev/null +++ b/docs/webhook_pr_body.json @@ -0,0 +1,224 @@ +{ + "action": "created", + "issue": { + "id": 1, + "url": "https://gitea.example.com/api/v1/repos/username/repo-name/issues/1", + "html_url": "https://gitea.example.com/username/repo-name/pulls/1", + "number": 1, + "user": { + "id": 1, + "login": "username", + "login_name": "", + "source_id": 0, + "full_name": "", + "email": "user@example.com", + "avatar_url": "https://gitea.example.com/avatars/aabbccdd", + "html_url": "https://gitea.example.com/username", + "language": "en-US", + "is_admin": true, + "last_login": "2026-01-01T00:00:00+02:00", + "created": "2025-01-01T00:00:00+02:00", + "restricted": false, + "active": true, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 0, + "username": "username" + }, + "original_author": "", + "original_author_id": 0, + "title": "impl webhook route", + "body": "", + "ref": "", + "assets": [], + "labels": [], + "milestone": null, + "assignee": null, + "assignees": null, + "state": "open", + "is_locked": false, + "comments": 1, + "created_at": "2026-01-01T00:00:00+02:00", + "updated_at": "2026-01-01T00:00:00+02:00", + "closed_at": null, + "due_date": null, + "time_estimate": 0, + "pull_request": { + "merged": false, + "merged_at": null, + "draft": false, + "html_url": "https://gitea.example.com/username/repo-name/pulls/1" + }, + "repository": { + "id": 8, + "name": "repo-name", + "owner": "username", + "full_name": "username/repo-name" + }, + "pin_order": 0, + "content_version": 0 + }, + "pull_request": { + "id": 1, + "url": "https://gitea.example.com/username/repo-name/pulls/1", + "number": 1, + "user": { + "id": 1, + "login": "username", + "login_name": "", + "source_id": 0, + "full_name": "", + "email": "user@example.com", + "avatar_url": "https://gitea.example.com/avatars/aabbccdd", + "html_url": "https://gitea.example.com/username", + "language": "en-US", + "is_admin": true, + "last_login": "2026-01-01T00:00:00+02:00", + "created": "2025-01-01T00:00:00+02:00", + "restricted": false, + "active": true, + "prohibit_login": false, + "location": "", + "website": "", + "description": "", + "visibility": "public", + "followers_count": 0, + "following_count": 0, + "starred_repos_count": 0, + "username": "username" + }, + "title": "impl webhook route", + "body": "", + "labels": [], + "milestone": null, + "assignee": null, + "assignees": [], + "requested_reviewers": [], + "requested_reviewers_teams": [], + "state": "open", + "draft": false, + "is_locked": false, + "comments": 1, + "review_comments": 0, + "additions": 3, + "deletions": 3, + "changed_files": 2, + "html_url": "https://gitea.example.com/username/repo-name/pulls/1", + "diff_url": "https://gitea.example.com/username/repo-name/pulls/1.diff", + "patch_url": "https://gitea.example.com/username/repo-name/pulls/1.patch", + "mergeable": true, + "merged": false, + "merged_at": null, + "merge_commit_sha": null, + "merged_by": null, + "allow_maintainer_edit": false, + "base": { + "label": "main", + "ref": "main", + "sha": "aabbccdd00112233445566778899aabbccdd0011", + "repo_id": 8, + "repo": { + "id": 8, + "owner": { + "id": 1, + "login": "username", + "email": "user@example.com", + "avatar_url": "https://gitea.example.com/avatars/aabbccdd", + "html_url": "https://gitea.example.com/username", + "username": "username" + }, + "name": "repo-name", + "full_name": "username/repo-name", + "description": "A self-hosted Gitea bot.", + "html_url": "https://gitea.example.com/username/repo-name", + "url": "https://gitea.example.com/api/v1/repos/username/repo-name", + "ssh_url": "git@gitea.example.com:username/repo-name.git", + "clone_url": "https://gitea.example.com/username/repo-name.git", + "default_branch": "main" + } + }, + "head": { + "label": "webhook", + "ref": "webhook", + "sha": "eeff00112233445566778899aabbccddeeff0011", + "repo_id": 8, + "repo": { + "id": 8, + "owner": { + "id": 1, + "login": "username", + "email": "user@example.com", + "avatar_url": "https://gitea.example.com/avatars/aabbccdd", + "html_url": "https://gitea.example.com/username", + "username": "username" + }, + "name": "repo-name", + "full_name": "username/repo-name", + "description": "A self-hosted Gitea bot.", + "html_url": "https://gitea.example.com/username/repo-name", + "url": "https://gitea.example.com/api/v1/repos/username/repo-name", + "ssh_url": "git@gitea.example.com:username/repo-name.git", + "clone_url": "https://gitea.example.com/username/repo-name.git", + "default_branch": "main" + } + }, + "merge_base": "aabbccdd00112233445566778899aabbccdd0011", + "due_date": null, + "created_at": "2026-01-01T00:00:00+02:00", + "updated_at": "2026-01-01T00:00:00+02:00", + "closed_at": null + }, + "comment": { + "id": 3, + "html_url": "https://gitea.example.com/username/repo-name/pulls/1#issuecomment-3", + "pull_request_url": "https://gitea.example.com/username/repo-name/pulls/1", + "issue_url": "", + "user": { + "id": 1, + "login": "username", + "email": "user@example.com", + "avatar_url": "https://gitea.example.com/avatars/aabbccdd", + "html_url": "https://gitea.example.com/username", + "username": "username" + }, + "original_author": "", + "original_author_id": 0, + "body": "Test comment", + "assets": [], + "created_at": "2026-01-01T00:00:00+02:00", + "updated_at": "2026-01-01T00:00:00+02:00" + }, + "repository": { + "id": 8, + "owner": { + "id": 1, + "login": "username", + "email": "user@example.com", + "avatar_url": "https://gitea.example.com/avatars/aabbccdd", + "html_url": "https://gitea.example.com/username", + "username": "username" + }, + "name": "repo-name", + "full_name": "username/repo-name", + "description": "A self-hosted Gitea bot.", + "html_url": "https://gitea.example.com/username/repo-name", + "url": "https://gitea.example.com/api/v1/repos/username/repo-name", + "ssh_url": "git@gitea.example.com:username/repo-name.git", + "clone_url": "https://gitea.example.com/username/repo-name.git", + "default_branch": "main" + }, + "sender": { + "id": 1, + "login": "username", + "email": "user@example.com", + "avatar_url": "https://gitea.example.com/avatars/aabbccdd", + "html_url": "https://gitea.example.com/username", + "username": "username" + }, + "is_pull": true +} \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index d77a738..a3647a0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,10 +1,15 @@ -use axum::Router; -use axum::routing::get; +use axum::response::{Response, IntoResponse}; +use axum::{Json, Router}; +use axum::extract::FromRequest; +use axum::routing::{post, get}; +use reqwest::StatusCode; +use serde_json::Value; -use crate::env; +use crate::env::{EnvConfig}; +use crate::gitea::WebhookType; -pub async fn start_api(config: env::EnvConfig) -> anyhow::Result<()> { - let app = Router::new().route("/webhook", get(webhook)); +pub async fn start_api(config: EnvConfig) -> anyhow::Result<()> { + let app = Router::new().route("/", get(root)).route("/webhook", post(webhook)); let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.http_port)).await?; axum::serve(listerner, app) @@ -12,6 +17,38 @@ pub async fn start_api(config: env::EnvConfig) -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!(e)) } -async fn webhook() -> &'static str { - "Hello, World!" +async fn root() -> &'static str { + "Hi, i'm Herald :)" +} + +async fn webhook(WebhookExtract(wb): WebhookExtract) -> Response { + match wb { + WebhookType::Review(id, _) => format!("Received {} pr id", id), + _ => String::from("Nothing to see :/") + }.into_response() +} + +pub struct WebhookExtract(pub WebhookType); + +impl FromRequest for WebhookExtract +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request( + req: axum::extract::Request, + state: &S, + ) -> Result { + let Json(value) = Json::::from_request(req, state) + .await + .map_err(|e| e.into_response())?; + + let webhook = WebhookType::try_from(value) + .map_err(|e| { + (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()).into_response() + })?; + + Ok(WebhookExtract(webhook)) + } } diff --git a/src/bot.rs b/src/bot.rs new file mode 100644 index 0000000..4e4fa76 --- /dev/null +++ b/src/bot.rs @@ -0,0 +1,5 @@ +use crate::gitea::WebhookType; + +pub async fn exec(webhook: WebhookType) { + +} \ No newline at end of file diff --git a/src/gitea.rs b/src/gitea.rs index e69de29..113e76d 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -0,0 +1,35 @@ +use anyhow::anyhow; +use serde_json::Value; + + +pub enum WebhookType { + Review(u64, String) +} + +impl TryFrom for WebhookType { + type Error = anyhow::Error; + + fn try_from(json: Value) -> Result { + let pull_request = json.get("pull_request"); + let comment = json.get("comment"); + + if let (Some(pull_request), Some(comment)) = (pull_request, comment) { + let comment_body = comment + .get("body") + .ok_or(anyhow!("comment body not found"))? + .as_str() + .ok_or(anyhow!("error while get pr comment"))? + .to_string(); + + let pr_id = pull_request + .get("id") + .ok_or(anyhow!("pr id not found"))? + .as_u64() + .ok_or(anyhow!("error while get pr id"))?; + + return Ok(WebhookType::Review(pr_id, comment_body)); + } + + anyhow::bail!("unknow webhook type") + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index d5f4d79..1dd3444 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod api; mod env; +mod gitea; +mod bot; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/src/claude.rs b/src/open_router.rs similarity index 100% rename from src/claude.rs rename to src/open_router.rs -- 2.52.0 From aa746f357d5c67f83f8f2490ceaabc21aaa3397c Mon Sep 17 00:00:00 2001 From: qpismont Date: Sun, 31 May 2026 20:32:49 +0000 Subject: [PATCH 3/7] add gitea sig header check, add Json errors, begin AppState --- .devcontainer/devcontainer.json | 2 +- Cargo.lock | 107 ++++++++++++++++++++++++++++++++ Cargo.toml | 6 ++ src/api.rs | 86 ++++++++++++++++++------- src/bot.rs | 14 ++++- src/env.rs | 23 ++++++- src/errors.rs | 40 ++++++++++++ src/gitea.rs | 18 ++++-- src/main.rs | 19 +++++- src/state.rs | 10 +++ 10 files changed, 288 insertions(+), 37 deletions(-) create mode 100644 src/errors.rs create mode 100644 src/state.rs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 745ef73..60f004e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,7 +14,7 @@ }, "customizations": { "vscode": { - "extensions": ["rust-lang.rust-analyzer"], + "extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml", "fill-labs.dependi"], "settings": { "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", diff --git a/Cargo.lock b/Cargo.lock index e4be325..cc29c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -145,6 +154,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -155,6 +170,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -181,6 +202,45 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -353,13 +413,34 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "bytes", "dotenvy", + "hex", + "hmac", "reqwest", "serde", "serde_json", + "sha2", + "subtle", + "thiserror", "tokio", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -405,6 +486,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1183,6 +1273,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1469,6 +1570,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 9650694..3893df5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,9 @@ serde = { version = "1.0", features = ["derive"] } dotenvy = "0.15" axum = "0.8" anyhow = "1.0" +thiserror = "2.0" +hmac = "0.13" +sha2 = "0.11" +hex = "0.4" +subtle = "2.6" +bytes = "1.11" \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index a3647a0..14ef828 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,16 +1,28 @@ -use axum::response::{Response, IntoResponse}; -use axum::{Json, Router}; +use anyhow::anyhow; +use axum::body::to_bytes; use axum::extract::FromRequest; -use axum::routing::{post, get}; -use reqwest::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use hmac::{Hmac, KeyInit, Mac}; use serde_json::Value; +use sha2::Sha256; +use subtle::ConstantTimeEq; -use crate::env::{EnvConfig}; +use crate::errors::AppError; use crate::gitea::WebhookType; +use crate::state::AppState; -pub async fn start_api(config: EnvConfig) -> anyhow::Result<()> { - let app = Router::new().route("/", get(root)).route("/webhook", post(webhook)); - let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.http_port)).await?; +const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 Mo + +pub async fn start(app_state: AppState) -> anyhow::Result<()> { + let http_port = app_state.config.http_port; + + let app = Router::new() + .with_state(app_state) + .route("/", get(root)) + .route("/webhook", post(webhook)); + let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?; axum::serve(listerner, app) .await @@ -21,11 +33,12 @@ async fn root() -> &'static str { "Hi, i'm Herald :)" } -async fn webhook(WebhookExtract(wb): WebhookExtract) -> Response { - match wb { +async fn webhook(WebhookExtract(wb): WebhookExtract) -> Result { + Ok(match wb { WebhookType::Review(id, _) => format!("Received {} pr id", id), - _ => String::from("Nothing to see :/") - }.into_response() + _ => String::from("Nothing to see :/"), + } + .into_response()) } pub struct WebhookExtract(pub WebhookType); @@ -34,21 +47,48 @@ impl FromRequest for WebhookExtract where S: Send + Sync, { - type Rejection = Response; + type Rejection = AppError; - async fn from_request( - req: axum::extract::Request, - state: &S, - ) -> Result { - let Json(value) = Json::::from_request(req, state) + async fn from_request(req: axum::extract::Request, state: &S) -> Result { + let headers = req.headers(); + let sig_header = headers + .get("x-gitea-signature") + .ok_or(AppError::WebHookSigHeaderNotFoundErr)? + .to_str() + .map_err(|err| anyhow!(err))? + .to_string(); + + let body = req.into_body(); + let body_bytes = to_bytes(body, MAX_WEBHOOK_BODY_SIZE) .await - .map_err(|e| e.into_response())?; + .map_err(|err| anyhow!(err))?; - let webhook = WebhookType::try_from(value) - .map_err(|e| { - (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()).into_response() - })?; + let Json(value) = + Json::::from_bytes(&body_bytes).map_err(|_| AppError::MalformedJsonErr)?; + let webhook = WebhookType::try_from(value)?; + + check_sig_header(sig_header.as_bytes(), &body_bytes)?; Ok(WebhookExtract(webhook)) } } + +fn check_sig_header(sig_header: &[u8], body: &[u8]) -> Result<(), AppError> { + let sig_header_decoded = hex::decode(sig_header).map_err(|err| anyhow!(err))?; + + 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))?; + + mac.update(body); + + let generated_hmac = mac.finalize().into_bytes(); + let check_result: bool = generated_hmac.ct_eq(&sig_header_decoded).into(); + + match check_result { + true => Ok(()), + false => Err(AppError::WebHookSigHeaderInvalidErr), + } +} diff --git a/src/bot.rs b/src/bot.rs index 4e4fa76..6e9ed0f 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,5 +1,13 @@ -use crate::gitea::WebhookType; +use crate::{env::EnvConfig, gitea::WebhookType}; -pub async fn exec(webhook: WebhookType) { +pub struct Bot { + config: EnvConfig, +} -} \ No newline at end of file +impl Bot { + pub fn new(config: EnvConfig) -> Self { + Self { config } + } + + pub async fn exec(&self, webhook: WebhookType) {} +} diff --git a/src/env.rs b/src/env.rs index 16002d7..6298de6 100644 --- a/src/env.rs +++ b/src/env.rs @@ -1,19 +1,36 @@ +use anyhow::anyhow; use dotenvy::dotenv; - +#[derive(Clone)] pub struct EnvConfig { pub http_port: u16, + pub webhook_secret: String, + pub open_router_api_key: String, pub bot_name: String, } pub fn load_config() -> anyhow::Result { dotenv().ok(); - let http_port = std::env::var("HTTP_PORT")?.parse()?; - let bot_name = std::env::var("BOT_NAME")?; + let http_port = try_get_env("HTTP_PORT")?.parse()?; + let bot_name = try_get_env("BOT_NAME")?; + let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; + let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; Ok(EnvConfig { http_port, + webhook_secret, bot_name, + open_router_api_key, }) } + +fn try_get_env(key: &str) -> anyhow::Result { + let env = std::env::var(key)?; + + if env.trim().len() == 0 { + return Err(anyhow!(format!("env var {} is empty", env))); + } + + Ok(env) +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..704956f --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,40 @@ +use axum::response::IntoResponse; +use reqwest::StatusCode; + +#[derive(thiserror::Error, Debug)] +pub enum AppError { + #[error("Malformed Json")] + MalformedJsonErr, + + #[error("Json not contains mandatory fields")] + BadJsonStructErr, + + #[error("WebHook sig header not found")] + WebHookSigHeaderNotFoundErr, + + #[error("WebHook sig header is invalid")] + WebHookSigHeaderInvalidErr, + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + match self { + AppError::MalformedJsonErr => (StatusCode::BAD_REQUEST, "Malformed Json"), + 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"), + } + .into_response() + } +} diff --git a/src/gitea.rs b/src/gitea.rs index 113e76d..d148cc9 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,17 +1,27 @@ use anyhow::anyhow; use serde_json::Value; +use crate::errors::AppError; pub enum WebhookType { - Review(u64, String) + Review(u64, String), } impl TryFrom for WebhookType { - type Error = anyhow::Error; + type Error = AppError; fn try_from(json: Value) -> Result { let pull_request = json.get("pull_request"); let comment = json.get("comment"); + let action = json + .get("action") + .ok_or(anyhow!("action not found"))? + .as_str() + .ok_or(anyhow!("error while action"))?; + + if action != "created" { + return Err(AppError::BadJsonStructErr); + } if let (Some(pull_request), Some(comment)) = (pull_request, comment) { let comment_body = comment @@ -30,6 +40,6 @@ impl TryFrom for WebhookType { return Ok(WebhookType::Review(pr_id, comment_body)); } - anyhow::bail!("unknow webhook type") + Err(AppError::BadJsonStructErr) } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 1dd3444..6c3db06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,24 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::{bot::Bot, state::AppState}; + mod api; -mod env; -mod gitea; mod bot; +mod env; +mod errors; +mod gitea; +mod state; #[tokio::main] async fn main() -> anyhow::Result<()> { let config = env::load_config()?; - api::start_api(config).await + let app_state = AppState { + bot: Arc::new(Mutex::new(Bot::new(config.clone()))), + config: config, + }; + + api::start(app_state).await } diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..28c3bfe --- /dev/null +++ b/src/state.rs @@ -0,0 +1,10 @@ +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::{bot::Bot, env::EnvConfig}; + +#[derive(Clone)] +pub struct AppState { + pub bot: Arc>, + pub config: EnvConfig, +} -- 2.52.0 From 227fcfaafbeca8a5c82215defcb8b8f8e42a0c07 Mon Sep 17 00:00:00 2001 From: qpismont Date: Sun, 31 May 2026 20:48:42 +0000 Subject: [PATCH 4/7] using state in extractor --- src/api.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/api.rs b/src/api.rs index 14ef828..61a968e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,9 +1,11 @@ +use std::fmt::Debug; + use anyhow::anyhow; use axum::body::to_bytes; -use axum::extract::FromRequest; +use axum::extract::{FromRef, FromRequest, FromRequestParts, State}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; -use axum::{Json, Router}; +use axum::{Json, RequestExt, Router}; use hmac::{Hmac, KeyInit, Mac}; use serde_json::Value; use sha2::Sha256; @@ -19,9 +21,9 @@ pub async fn start(app_state: AppState) -> anyhow::Result<()> { let http_port = app_state.config.http_port; let app = Router::new() - .with_state(app_state) .route("/", get(root)) - .route("/webhook", post(webhook)); + .route("/webhook", post(webhook)) + .with_state(app_state); let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?; axum::serve(listerner, app) @@ -45,11 +47,22 @@ pub struct WebhookExtract(pub WebhookType); impl FromRequest for WebhookExtract where + AppState: FromRef, S: Send + Sync, { type Rejection = AppError; - async fn from_request(req: axum::extract::Request, state: &S) -> Result { + async fn from_request( + mut req: axum::extract::Request, + state: &S, + ) -> Result { + let State(state) = req + .extract_parts_with_state::, _>(state) + .await + .unwrap(); + + let secret_key = state.config.webhook_secret.as_bytes(); + let headers = req.headers(); let sig_header = headers .get("x-gitea-signature") @@ -63,17 +76,17 @@ where .await .map_err(|err| anyhow!(err))?; + check_sig_header(secret_key, sig_header.as_bytes(), &body_bytes)?; + let Json(value) = Json::::from_bytes(&body_bytes).map_err(|_| AppError::MalformedJsonErr)?; let webhook = WebhookType::try_from(value)?; - check_sig_header(sig_header.as_bytes(), &body_bytes)?; - Ok(WebhookExtract(webhook)) } } -fn check_sig_header(sig_header: &[u8], body: &[u8]) -> Result<(), AppError> { +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 webhook_sig_header_secret = -- 2.52.0 From 7e3b49ad76822a96db49dfff324598f59258d803 Mon Sep 17 00:00:00 2001 From: qpismont Date: Sun, 31 May 2026 21:30:17 +0000 Subject: [PATCH 5/7] add test + json errors --- src/api.rs | 40 ++++++++++-- src/errors.rs | 48 +++++++++++---- src/gitea.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 231 insertions(+), 24 deletions(-) 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())); + } +} -- 2.52.0 From 0a22be252cc6aba0e993fe9dceda61a0f238fdb5 Mon Sep 17 00:00:00 2001 From: qpismont Date: Mon, 1 Jun 2026 21:10:45 +0000 Subject: [PATCH 6/7] improve webhook parsing --- src/api.rs | 144 ++++++++++++---------------- src/consts.rs | 3 + src/env.rs | 8 +- src/errors.rs | 60 +++++------- src/gitea.rs | 261 +++++++++++++++++++------------------------------- src/main.rs | 1 + 6 files changed, 193 insertions(+), 284 deletions(-) create mode 100644 src/consts.rs diff --git a/src/api.rs b/src/api.rs index 41ea0ab..6e0e66f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,34 +1,28 @@ -use std::fmt::Debug; - -use anyhow::anyhow; -use axum::body::to_bytes; -use axum::extract::{FromRef, FromRequest, FromRequestParts, State}; +use axum::body::{Bytes, to_bytes}; +use axum::extract::{FromRef, FromRequest}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; -use axum::{Json, RequestExt, Router}; +use axum::{Json, Router}; use hmac::{Hmac, KeyInit, Mac}; use serde_json::Value; use sha2::Sha256; use subtle::ConstantTimeEq; +use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE}; use crate::errors::AppError; use crate::gitea::WebhookType; use crate::state::AppState; -const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 Mo - pub async fn start(app_state: AppState) -> anyhow::Result<()> { let http_port = app_state.config.http_port; - let app = Router::new() .route("/", get(root)) .route("/webhook", post(webhook)) .with_state(app_state); - let listerner = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?; - - axum::serve(listerner, app) + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?; + axum::serve(listener, app) .await - .map_err(|e| anyhow::anyhow!(e)) + .map_err(anyhow::Error::from) } async fn root() -> &'static str { @@ -36,11 +30,7 @@ async fn root() -> &'static str { } async fn webhook(WebhookExtract(wb): WebhookExtract) -> Result { - Ok(match wb { - WebhookType::Review(id, _) => format!("Received {} pr id", id), - _ => String::from("Nothing to see :/"), - } - .into_response()) + Ok("lol".into_response()) } pub struct WebhookExtract(pub WebhookType); @@ -52,84 +42,70 @@ where { type Rejection = AppError; - async fn from_request( - mut req: axum::extract::Request, - state: &S, - ) -> Result { - let State(state) = req - .extract_parts_with_state::, _>(state) - .await - .unwrap(); - - let secret_key = state.config.webhook_secret.as_bytes(); - + async fn from_request(req: axum::extract::Request, state: &S) -> Result { + let app_state = AppState::from_ref(state); let headers = req.headers(); - let sig_header = headers - .get("x-gitea-signature") - .ok_or(AppError::WebHookSigHeaderNotFoundErr)? - .to_str() - .map_err(|err| anyhow!(err))? - .to_string(); - let body = req.into_body(); - let body_bytes = to_bytes(body, MAX_WEBHOOK_BODY_SIZE) - .await - .map_err(|err| anyhow!(err))?; + let sig_header = extract_header(GITEA_SIG_HEADER_NAME, headers)?; + let type_header = extract_header(GITEA_EVENT_TYPE_HEADER_NAME, headers)?; + let body_bytes = read_body(req.into_body()).await?; - check_sig_header(secret_key, sig_header.as_bytes(), &body_bytes)?; + verify_signature( + app_state.config.webhook_secret.as_bytes(), + &sig_header, + &body_bytes, + )?; - let Json(value) = - Json::::from_bytes(&body_bytes).map_err(|_| AppError::MalformedJsonErr)?; - let webhook = WebhookType::try_from(value)?; + let webhook = parse_webhook(&type_header, &body_bytes)?; + reject_bot_user(&app_state, &webhook)?; Ok(WebhookExtract(webhook)) } } -fn check_sig_header(secret_key: &[u8], sig_header: &[u8], body: &[u8]) -> Result<(), AppError> { - let sig_header_decoded = hex::decode(sig_header).map_err(|_| AppError::WebHookSigHeaderInvalidErr)?; +fn extract_header(key: &str, headers: &axum::http::HeaderMap) -> Result { + let value = headers + .get(key) + .ok_or(AppError::WebHookMissingHeaderErr(key.into()))? + .to_str() + .map_err(anyhow::Error::from)?; + Ok(value.to_owned()) +} - let mut mac = Hmac::::new_from_slice(secret_key).map_err(|err| anyhow!(err))?; +async fn read_body(body: axum::body::Body) -> Result { + to_bytes(body, MAX_WEBHOOK_BODY_SIZE) + .await + .map_err(anyhow::Error::from) + .map_err(AppError::from) +} + +fn parse_webhook(header: &str, body_bytes: &[u8]) -> Result { + let Json(value) = + Json::::from_bytes(body_bytes).map_err(|_| AppError::MalformedJsonErr)?; + + WebhookType::from_event(header, value) +} + +fn reject_bot_user(state: &AppState, webhook: &WebhookType) -> Result<(), AppError> { + let user_id = match webhook { + WebhookType::Review(review_payload) => review_payload.comment.user.id, + }; + + match user_id != state.config.bot_user_id { + true => Ok(()), + false => Err(AppError::UnauthorizedUserIdErr), + } +} + +fn verify_signature(secret_key: &[u8], sig_header: &str, body: &[u8]) -> Result<(), AppError> { + let sig_header_decoded = + hex::decode(sig_header).map_err(|_| AppError::WebHookSigHeaderInvalidErr)?; + let mut mac = Hmac::::new_from_slice(secret_key).map_err(anyhow::Error::from)?; mac.update(body); let generated_hmac = mac.finalize().into_bytes(); - let check_result: bool = generated_hmac.ct_eq(&sig_header_decoded).into(); - - match check_result { - true => Ok(()), - 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()); - } + bool::from(generated_hmac.ct_eq(&sig_header_decoded)) + .then_some(()) + .ok_or(AppError::WebHookSigHeaderInvalidErr) } diff --git a/src/consts.rs b/src/consts.rs new file mode 100644 index 0000000..75ebdab --- /dev/null +++ b/src/consts.rs @@ -0,0 +1,3 @@ +pub const GITEA_SIG_HEADER_NAME: &str = "x-gitea-signature"; +pub const GITEA_EVENT_TYPE_HEADER_NAME: &str = "x-gitea-event-type"; +pub const MAX_WEBHOOK_BODY_SIZE: usize = 1024 * 1024; // 1 MiB diff --git a/src/env.rs b/src/env.rs index 6298de6..6b8ef2a 100644 --- a/src/env.rs +++ b/src/env.rs @@ -6,21 +6,21 @@ pub struct EnvConfig { pub http_port: u16, pub webhook_secret: String, pub open_router_api_key: String, - pub bot_name: String, + pub bot_user_id: u64, } pub fn load_config() -> anyhow::Result { dotenv().ok(); let http_port = try_get_env("HTTP_PORT")?.parse()?; - let bot_name = try_get_env("BOT_NAME")?; + let bot_user_id = try_get_env("BOT_USER_ID")?.parse()?; let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; Ok(EnvConfig { http_port, webhook_secret, - bot_name, + bot_user_id, open_router_api_key, }) } @@ -28,7 +28,7 @@ pub fn load_config() -> anyhow::Result { fn try_get_env(key: &str) -> anyhow::Result { let env = std::env::var(key)?; - if env.trim().len() == 0 { + if env.trim().is_empty() { return Err(anyhow!(format!("env var {} is empty", env))); } diff --git a/src/errors.rs b/src/errors.rs index 16f4804..459eb42 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -3,24 +3,24 @@ use reqwest::StatusCode; #[derive(thiserror::Error, Debug)] pub enum AppError { + #[error("Unauthorized user id")] + UnauthorizedUserIdErr, + + #[error("Unknow gitea event")] + UnknownEventErr, + #[error("Malformed Json")] MalformedJsonErr, - #[error("Json not contains mandatory fields")] - BadJsonStructErr, + #[error(transparent)] + BadJsonStructErr(#[from] serde_json::Error), - #[error("WebHook sig header not found")] - WebHookSigHeaderNotFoundErr, + #[error("WebHook header not found")] + WebHookMissingHeaderErr(String), #[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), } @@ -28,39 +28,29 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { - AppError::MalformedJsonErr => { - (StatusCode::BAD_REQUEST, "Malformed Json".to_string()).into_response() + AppError::UnknownEventErr => { + (StatusCode::BAD_REQUEST, "Unknow gitea event".to_string()) } - AppError::BadJsonStructErr => ( + AppError::UnauthorizedUserIdErr => { + (StatusCode::BAD_REQUEST, "Unauthorized user id".to_string()) + } + AppError::MalformedJsonErr => (StatusCode::BAD_REQUEST, "Malformed Json".to_string()), + AppError::BadJsonStructErr(err) => ( StatusCode::BAD_REQUEST, - "Json not contains mandatory fields".to_string(), - ) - .into_response(), - AppError::WebHookSigHeaderNotFoundErr => ( - StatusCode::BAD_REQUEST, - "WebHook sig header not found".to_string(), - ) - .into_response(), + format!("Json not contains mandatory fields: {}", err), + ), + AppError::WebHookMissingHeaderErr(h) => { + (StatusCode::BAD_REQUEST, format!("header {} is missing", h)) + } 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 5d1d1b8..b82af15 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,46 +1,43 @@ +use serde::Deserialize; use serde_json::Value; use crate::errors::AppError; -#[derive(Debug, PartialEq)] +#[derive(Debug)] pub enum WebhookType { - Review(u64, String), + Review(ReviewPayload), } -impl TryFrom for WebhookType { - type Error = AppError; +#[derive(Deserialize, Debug)] +pub struct ReviewPayload { + pub action: String, + pub pull_request: PullRequest, + pub comment: Comment, +} - fn try_from(json: Value) -> Result { - let pull_request = json.get("pull_request"); - let comment = json.get("comment"); - let action = json - .get("action") - .ok_or(AppError::MissingField("action".into()))? - .as_str() - .ok_or(AppError::WrongFieldType("action".into()))?; +#[derive(Deserialize, Debug)] +pub struct PullRequest { + pub id: u64, +} - if action != "created" { - return Err(AppError::BadJsonStructErr); +#[derive(Deserialize, Debug)] +pub struct Comment { + pub id: u64, + pub body: String, + pub user: User, +} + +#[derive(Deserialize, Debug)] +pub struct User { + pub id: u64, +} + +impl WebhookType { + pub fn from_event(event: &str, json: Value) -> Result { + match event { + "pull_request_comment" => Ok(WebhookType::Review(serde_json::from_value(json)?)), + _ => Err(AppError::UnknownEventErr), } - - if let (Some(pull_request), Some(comment)) = (pull_request, comment) { - let comment_body = comment - .get("body") - .ok_or(AppError::MissingField("comment.body".into()))? - .as_str() - .ok_or(AppError::WrongFieldType("comment.body".into()))? - .to_string(); - - let pr_id = pull_request - .get("id") - .ok_or(AppError::MissingField("pull_request.id".into()))? - .as_u64() - .ok_or(AppError::WrongFieldType("pull_request.id".into()))?; - - return Ok(WebhookType::Review(pr_id, comment_body)); - } - - Err(AppError::BadJsonStructErr) } } @@ -50,149 +47,91 @@ mod tests { use serde_json::json; #[test] - fn valid_webhook_parses_review() { - let payload = json!({ + fn test_from_event_valid_pull_request_comment() { + let json = json!({ "action": "created", - "pull_request": { "id": 42 }, - "comment": { "body": "LGTM" } + "pull_request": { + "id": 42 + }, + "comment": { + "id": 7, + "body": "LGTM", + "user": { + "id": 100 + } + } }); - let result = WebhookType::try_from(payload).unwrap(); - assert_eq!(result, WebhookType::Review(42, "LGTM".into())); + + let result = WebhookType::from_event("pull_request_comment", json); + assert!(result.is_ok()); + + match result.unwrap() { + WebhookType::Review(payload) => { + assert_eq!(payload.action, "created"); + assert_eq!(payload.pull_request.id, 42); + assert_eq!(payload.comment.id, 7); + assert_eq!(payload.comment.body, "LGTM"); + assert_eq!(payload.comment.user.id, 100); + } + } } #[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")); + fn test_from_event_unknown_event() { + let json = json!({}); + let result = WebhookType::from_event("push", json); + assert!(result.is_err()); + + match result.unwrap_err() { + AppError::UnknownEventErr => {} + _ => panic!("expected UnknownEventErr"), + } } #[test] - fn action_not_created_returns_bad_json_struct() { - let payload = json!({ - "action": "updated", - "pull_request": { "id": 1 }, - "comment": { "body": "ok" } + fn test_from_event_malformed_json() { + let json = json!({ + "action": "created" + // pull_request and comment are missing }); - let err = WebhookType::try_from(payload).unwrap_err(); - assert!(matches!(err, AppError::BadJsonStructErr)); + + let result = WebhookType::from_event("pull_request_comment", json); + assert!(result.is_err()); + + match result.unwrap_err() { + AppError::BadJsonStructErr(_) => {} + _ => panic!("expected BadJsonStructErr"), + } } #[test] - fn action_not_a_string_returns_error() { - let payload = json!({ - "action": 123, - "pull_request": { "id": 1 }, - "comment": { "body": "ok" } + fn test_deserialize_review_payload() { + let json = json!({ + "action": "edited", + "pull_request": { + "id": 99 + }, + "comment": { + "id": 12, + "body": "Needs work", + "user": { + "id": 200 + } + } }); - let err = WebhookType::try_from(payload).unwrap_err(); - assert!(matches!(err, AppError::WrongFieldType(ref f) if f == "action")); + + let payload: ReviewPayload = serde_json::from_value(json).unwrap(); + assert_eq!(payload.action, "edited"); + assert_eq!(payload.pull_request.id, 99); + assert_eq!(payload.comment.id, 12); + assert_eq!(payload.comment.body, "Needs work"); + assert_eq!(payload.comment.user.id, 200); } #[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())); + fn test_from_event_empty_json() { + let result = WebhookType::from_event("pull_request_comment", json!({})); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), AppError::BadJsonStructErr(_))); } } diff --git a/src/main.rs b/src/main.rs index 6c3db06..83830ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use crate::{bot::Bot, state::AppState}; mod api; mod bot; +mod consts; mod env; mod errors; mod gitea; -- 2.52.0 From 14751f8db1d6f358272b2e9ae1b201f5b0b2a092 Mon Sep 17 00:00:00 2001 From: qpismont Date: Tue, 2 Jun 2026 18:42:59 +0000 Subject: [PATCH 7/7] add check for action and bot_name --- src/api.rs | 22 +++------- src/env.rs | 8 ++-- src/errors.rs | 22 ++++++---- src/gitea.rs | 113 ++++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 126 insertions(+), 39 deletions(-) diff --git a/src/api.rs b/src/api.rs index 6e0e66f..a62d234 100644 --- a/src/api.rs +++ b/src/api.rs @@ -15,10 +15,12 @@ use crate::state::AppState; pub async fn start(app_state: AppState) -> anyhow::Result<()> { let http_port = app_state.config.http_port; + let app = Router::new() .route("/", get(root)) .route("/webhook", post(webhook)) .with_state(app_state); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", http_port)).await?; axum::serve(listener, app) .await @@ -56,9 +58,7 @@ where &body_bytes, )?; - let webhook = parse_webhook(&type_header, &body_bytes)?; - reject_bot_user(&app_state, &webhook)?; - + let webhook = parse_webhook(&type_header, &app_state.config.bot_name, &body_bytes)?; Ok(WebhookExtract(webhook)) } } @@ -69,6 +69,7 @@ fn extract_header(key: &str, headers: &axum::http::HeaderMap) -> Result Result { .map_err(AppError::from) } -fn parse_webhook(header: &str, body_bytes: &[u8]) -> Result { +fn parse_webhook(header: &str, bot_name: &str, body_bytes: &[u8]) -> Result { let Json(value) = Json::::from_bytes(body_bytes).map_err(|_| AppError::MalformedJsonErr)?; - WebhookType::from_event(header, value) -} - -fn reject_bot_user(state: &AppState, webhook: &WebhookType) -> Result<(), AppError> { - let user_id = match webhook { - WebhookType::Review(review_payload) => review_payload.comment.user.id, - }; - - match user_id != state.config.bot_user_id { - true => Ok(()), - false => Err(AppError::UnauthorizedUserIdErr), - } + WebhookType::from_event(header, bot_name, value) } fn verify_signature(secret_key: &[u8], sig_header: &str, body: &[u8]) -> Result<(), AppError> { diff --git a/src/env.rs b/src/env.rs index 6b8ef2a..e4a8d1a 100644 --- a/src/env.rs +++ b/src/env.rs @@ -6,21 +6,21 @@ pub struct EnvConfig { pub http_port: u16, pub webhook_secret: String, pub open_router_api_key: String, - pub bot_user_id: u64, + pub bot_name: String, } pub fn load_config() -> anyhow::Result { dotenv().ok(); let http_port = try_get_env("HTTP_PORT")?.parse()?; - let bot_user_id = try_get_env("BOT_USER_ID")?.parse()?; + let bot_name = try_get_env("BOT_NAME")?; let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; Ok(EnvConfig { http_port, webhook_secret, - bot_user_id, + bot_name, open_router_api_key, }) } @@ -29,7 +29,7 @@ fn try_get_env(key: &str) -> anyhow::Result { let env = std::env::var(key)?; if env.trim().is_empty() { - return Err(anyhow!(format!("env var {} is empty", env))); + return Err(anyhow!(format!("env var {} is empty", key))); } Ok(env) diff --git a/src/errors.rs b/src/errors.rs index 459eb42..95b70ae 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -4,7 +4,7 @@ use reqwest::StatusCode; #[derive(thiserror::Error, Debug)] pub enum AppError { #[error("Unauthorized user id")] - UnauthorizedUserIdErr, + UnauthorizedUserErr, #[error("Unknow gitea event")] UnknownEventErr, @@ -12,15 +12,18 @@ pub enum AppError { #[error("Malformed Json")] MalformedJsonErr, - #[error(transparent)] - BadJsonStructErr(#[from] serde_json::Error), - #[error("WebHook header not found")] WebHookMissingHeaderErr(String), #[error("WebHook sig header is invalid")] WebHookSigHeaderInvalidErr, + #[error("WebHook have bad action")] + InvalidActionErr, + + #[error(transparent)] + BadJsonStructErr(#[from] serde_json::Error), + #[error(transparent)] Other(#[from] anyhow::Error), } @@ -28,12 +31,17 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { match self { + AppError::InvalidActionErr => ( + StatusCode::UNPROCESSABLE_ENTITY, + "WebHook have bad action".to_string(), + ), AppError::UnknownEventErr => { (StatusCode::BAD_REQUEST, "Unknow gitea event".to_string()) } - AppError::UnauthorizedUserIdErr => { - (StatusCode::BAD_REQUEST, "Unauthorized user id".to_string()) - } + AppError::UnauthorizedUserErr => ( + StatusCode::UNAUTHORIZED, + "Unauthorized user name".to_string(), + ), AppError::MalformedJsonErr => (StatusCode::BAD_REQUEST, "Malformed Json".to_string()), AppError::BadJsonStructErr(err) => ( StatusCode::BAD_REQUEST, diff --git a/src/gitea.rs b/src/gitea.rs index b82af15..b5ab824 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -18,6 +18,7 @@ pub struct ReviewPayload { #[derive(Deserialize, Debug)] pub struct PullRequest { pub id: u64, + pub diff_url: String, } #[derive(Deserialize, Debug)] @@ -33,11 +34,29 @@ pub struct User { } impl WebhookType { - pub fn from_event(event: &str, json: Value) -> Result { - match event { + pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result { + let wb = match event { "pull_request_comment" => Ok(WebhookType::Review(serde_json::from_value(json)?)), _ => Err(AppError::UnknownEventErr), + }?; + + let pr_body = match &wb { + WebhookType::Review(review_payload) => &review_payload.comment.body, + }; + + if !pr_body.starts_with(&format!("@{}", bot_name)) { + return Err(AppError::UnauthorizedUserErr); } + + let action = match &wb { + WebhookType::Review(review_payload) => &review_payload.action, + }; + + if action != "created" { + return Err(AppError::InvalidActionErr); + } + + Ok(wb) } } @@ -51,18 +70,19 @@ mod tests { let json = json!({ "action": "created", "pull_request": { - "id": 42 + "id": 42, + "diff_url": "https://mydiff.fr" }, "comment": { "id": 7, - "body": "LGTM", + "body": "@test_bot LGTM", "user": { "id": 100 } } }); - let result = WebhookType::from_event("pull_request_comment", json); + let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(result.is_ok()); match result.unwrap() { @@ -70,7 +90,7 @@ mod tests { assert_eq!(payload.action, "created"); assert_eq!(payload.pull_request.id, 42); assert_eq!(payload.comment.id, 7); - assert_eq!(payload.comment.body, "LGTM"); + assert_eq!(payload.comment.body, "@test_bot LGTM"); assert_eq!(payload.comment.user.id, 100); } } @@ -79,7 +99,7 @@ mod tests { #[test] fn test_from_event_unknown_event() { let json = json!({}); - let result = WebhookType::from_event("push", json); + let result = WebhookType::from_event("push", "test_bot", json); assert!(result.is_err()); match result.unwrap_err() { @@ -95,7 +115,7 @@ mod tests { // pull_request and comment are missing }); - let result = WebhookType::from_event("pull_request_comment", json); + let result = WebhookType::from_event("pull_request_comment", "test_bot", json); assert!(result.is_err()); match result.unwrap_err() { @@ -105,11 +125,38 @@ mod tests { } #[test] - fn test_deserialize_review_payload() { + fn test_from_event_rejects_non_created_action() { let json = json!({ "action": "edited", "pull_request": { - "id": 99 + "id": 1, + "diff_url": "https://mydiff.fr" + }, + "comment": { + "id": 1, + "body": "@test_bot body", + "user": { + "id": 1 + } + } + }); + + let result = WebhookType::from_event("pull_request_comment", "test_bot", json); + assert!(result.is_err()); + + match result.unwrap_err() { + AppError::InvalidActionErr => {} + _ => panic!("expected InvalidActionErr"), + } + } + + #[test] + fn test_deserialize_review_payload() { + let json = json!({ + "action": "created", + "pull_request": { + "id": 99, + "diff_url": "https://mydiff.fr" }, "comment": { "id": 12, @@ -121,7 +168,7 @@ mod tests { }); let payload: ReviewPayload = serde_json::from_value(json).unwrap(); - assert_eq!(payload.action, "edited"); + assert_eq!(payload.action, "created"); assert_eq!(payload.pull_request.id, 99); assert_eq!(payload.comment.id, 12); assert_eq!(payload.comment.body, "Needs work"); @@ -130,8 +177,50 @@ mod tests { #[test] fn test_from_event_empty_json() { - let result = WebhookType::from_event("pull_request_comment", json!({})); + let result = WebhookType::from_event("pull_request_comment", "test_bot", json!({})); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), AppError::BadJsonStructErr(_))); } + + #[test] + fn test_from_event_rejects_wrong_bot_name() { + let json = json!({ + "action": "created", + "pull_request": { + "id": 1, + "diff_url": "https://mydiff.fr" + }, + "comment": { + "id": 1, + "body": "@other_bot do something", + "user": { + "id": 1 + } + } + }); + + let result = WebhookType::from_event("pull_request_comment", "test_bot", json); + assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr)); + } + + #[test] + fn test_from_event_rejects_no_bot_prefix() { + let json = json!({ + "action": "created", + "pull_request": { + "id": 1, + "diff_url": "https://mydiff.fr" + }, + "comment": { + "id": 1, + "body": "just a comment without bot mention", + "user": { + "id": 1 + } + } + }); + + let result = WebhookType::from_event("pull_request_comment", "test_bot", json); + assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr)); + } } -- 2.52.0