From e590bce3af033d276de4829c997c9a6b68a8f687 Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Mon, 22 Jun 2026 01:15:43 -0400 Subject: [PATCH 1/4] feat(agent_api_keys): one-call webhook-trigger setup endpoint Add POST /agent_api_keys/webhook-trigger: registers a github/slack signature key for an agent and returns the ready-to-paste forward webhook URL + secret in one call. Bundles the existing key-create with webhook-URL composition so a UI (or a curl) can wire a trigger in a single step; the webhook then flows through the existing /agents/forward ingress that verifies the signature against this key. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex/openapi.yaml | 126 ++++++++++++++++++ agentex/src/api/routes/agent_api_keys.py | 74 ++++++++++ agentex/src/api/schemas/agent_api_keys.py | 50 +++++++ .../tests/unit/api/test_webhook_trigger.py | 98 ++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 agentex/tests/unit/api/test_webhook_trigger.py diff --git a/agentex/openapi.yaml b/agentex/openapi.yaml index 2bfeff3c..2a6cf1c3 100644 --- a/agentex/openapi.yaml +++ b/agentex/openapi.yaml @@ -2722,6 +2722,47 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/webhook-trigger: + post: + tags: + - Agent APIKeys + summary: Create Webhook Trigger + description: 'Wire a webhook trigger in one call. + + + Registers the source''s signature-verification key (github/slack) for the + agent and + + returns the ready-to-paste forward webhook URL plus the signing secret (shown + once). + + The webhook then flows through the existing /agents/forward ingress, which + verifies + + the signature against this key. Bundles the existing key-create + URL composition + so + + a UI (or a curl) can set up a trigger without two steps.' + operationId: create_webhook_trigger_agent_api_keys_webhook_trigger_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWebhookTriggerRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWebhookTriggerResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /agent_api_keys/name/{name}: get: tags: @@ -4549,6 +4590,91 @@ components: to the agent inside the ACP payload for backward compatibility. type: object title: CreateTaskRequest + CreateWebhookTriggerRequest: + properties: + agent_name: + type: string + title: Agent Name + description: The agent the webhook drives. + source: + $ref: '#/components/schemas/AgentAPIKeyType' + description: Webhook source whose signature is verified (github or slack). + default: github + name: + type: string + title: Name + description: 'Signature-lookup key: the repo full_name (github) or api_app_id + (slack) that the forward ingress matches the incoming webhook against.' + forward_path: + type: string + title: Forward Path + description: Subpath the agent's own route handles, e.g. 'github-pr/'. + Appended to /agents/forward/name/{agent_name}/ to form the webhook URL. + secret: + anyOf: + - type: string + - type: 'null' + title: Secret + description: Optional signing secret; if unset, one is generated and returned. + base_url: + anyOf: + - type: string + - type: 'null' + title: Base Url + description: Optional public agentex base URL for the returned webhook_url; + defaults to the AGENTEX_PUBLIC_URL env var. + type: object + required: + - agent_name + - name + - forward_path + title: CreateWebhookTriggerRequest + description: 'One-call setup for a webhook trigger: register the source''s signature + key and + + get back the ready-to-paste forward webhook URL.' + CreateWebhookTriggerResponse: + properties: + key_id: + type: string + title: Key Id + description: The created agent API key id. + agent_name: + type: string + title: Agent Name + description: The agent the webhook drives. + source: + $ref: '#/components/schemas/AgentAPIKeyType' + description: Webhook source (github or slack). + name: + type: string + title: Name + description: Signature-lookup key (repo full_name / api_app_id). + secret: + type: string + title: Secret + description: The signing secret — shown once; paste into the source's webhook + config. + webhook_path: + type: string + title: Webhook Path + description: The forward path to POST webhooks to. + webhook_url: + anyOf: + - type: string + - type: 'null' + title: Webhook Url + description: Full webhook URL to paste into the source (None if no base + URL configured). + type: object + required: + - key_id + - agent_name + - source + - name + - secret + - webhook_path + title: CreateWebhookTriggerResponse DataContent: properties: type: diff --git a/agentex/src/api/routes/agent_api_keys.py b/agentex/src/api/routes/agent_api_keys.py index b7caebbe..e853ea62 100644 --- a/agentex/src/api/routes/agent_api_keys.py +++ b/agentex/src/api/routes/agent_api_keys.py @@ -1,3 +1,4 @@ +import os import secrets from fastapi import APIRouter, HTTPException, Query @@ -7,6 +8,8 @@ AgentAPIKey, CreateAPIKeyRequest, CreateAPIKeyResponse, + CreateWebhookTriggerRequest, + CreateWebhookTriggerResponse, ) from src.api.schemas.authorization_types import ( AgentexResourceType, @@ -93,6 +96,77 @@ async def create_api_key( ) +@router.post( + "/webhook-trigger", + response_model=CreateWebhookTriggerResponse, +) +async def create_webhook_trigger( + request: CreateWebhookTriggerRequest, + agent_api_key_use_case: DAgentAPIKeysUseCase, + agent_use_case: DAgentsUseCase, + authorization_service: DAuthorizationService, +) -> CreateWebhookTriggerResponse: + """Wire a webhook trigger in one call. + + Registers the source's signature-verification key (github/slack) for the agent and + returns the ready-to-paste forward webhook URL plus the signing secret (shown once). + The webhook then flows through the existing /agents/forward ingress, which verifies + the signature against this key. Bundles the existing key-create + URL composition so + a UI (or a curl) can set up a trigger without two steps. + """ + if request.source not in (AgentAPIKeyType.GITHUB, AgentAPIKeyType.SLACK): + raise HTTPException( + status_code=400, + detail="source must be 'github' or 'slack' for a webhook trigger.", + ) + agent = await agent_use_case.get(name=request.agent_name) + + # No api_key resource exists yet, so gate on the parent agent (update). + await _check_agent_or_collapse_to_404( + authorization_service, + agent.id, + AuthorizedOperationType.update, + ) + + existing_api_key = await agent_api_key_use_case.get_by_agent_id_and_name( + agent_id=agent.id, + name=request.name, + api_key_type=request.source, + ) + if existing_api_key: + # A duplicate is an expected client condition (409), not a server error, and the + # message avoids leaking the internal agent UUID the caller never supplied. + raise HTTPException( + status_code=409, + detail=f"A {request.source} webhook key named '{request.name}' already exists for this agent.", + ) + + secret = request.secret or secrets.token_hex(32) + agent_api_key_entity = await agent_api_key_use_case.create( + agent_id=agent.id, + api_key=str(secret), + name=request.name, + api_key_type=request.source, + ) + + forward_path = request.forward_path.lstrip("/") + webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}" + base_url = (request.base_url or os.environ.get("AGENTEX_PUBLIC_URL", "")).rstrip( + "/" + ) + webhook_url = f"{base_url}{webhook_path}" if base_url else None + + return CreateWebhookTriggerResponse( + key_id=agent_api_key_entity.id, + agent_name=request.agent_name, + source=agent_api_key_entity.api_key_type, + name=request.name, + secret=str(secret), + webhook_path=webhook_path, + webhook_url=webhook_url, + ) + + @router.get( "", response_model=list[AgentAPIKey], diff --git a/agentex/src/api/schemas/agent_api_keys.py b/agentex/src/api/schemas/agent_api_keys.py index cbd3aefc..481ae1b3 100644 --- a/agentex/src/api/schemas/agent_api_keys.py +++ b/agentex/src/api/schemas/agent_api_keys.py @@ -53,3 +53,53 @@ class CreateAPIKeyResponse(BaseModel): ..., description="The value of the newly created API key.", ) + + +class CreateWebhookTriggerRequest(BaseModel): + """One-call setup for a webhook trigger: register the source's signature key and + get back the ready-to-paste forward webhook URL.""" + + agent_name: str = Field(..., description="The agent the webhook drives.") + source: AgentAPIKeyType = Field( + AgentAPIKeyType.GITHUB, + description="Webhook source whose signature is verified (github or slack).", + ) + name: str = Field( + ..., + description="Signature-lookup key: the repo full_name (github) or api_app_id " + "(slack) that the forward ingress matches the incoming webhook against.", + ) + forward_path: str = Field( + ..., + description="Subpath the agent's own route handles, e.g. 'github-pr/'. " + "Appended to /agents/forward/name/{agent_name}/ to form the webhook URL.", + ) + secret: str | None = Field( + None, + description="Optional signing secret; if unset, one is generated and returned.", + ) + base_url: str | None = Field( + None, + description="Optional public agentex base URL for the returned webhook_url; " + "defaults to the AGENTEX_PUBLIC_URL env var.", + ) + + +class CreateWebhookTriggerResponse(BaseModel): + key_id: str = Field(..., description="The created agent API key id.") + agent_name: str = Field(..., description="The agent the webhook drives.") + source: AgentAPIKeyType = Field( + ..., description="Webhook source (github or slack)." + ) + name: str = Field( + ..., description="Signature-lookup key (repo full_name / api_app_id)." + ) + secret: str = Field( + ..., + description="The signing secret — shown once; paste into the source's webhook config.", + ) + webhook_path: str = Field(..., description="The forward path to POST webhooks to.") + webhook_url: str | None = Field( + None, + description="Full webhook URL to paste into the source (None if no base URL configured).", + ) diff --git a/agentex/tests/unit/api/test_webhook_trigger.py b/agentex/tests/unit/api/test_webhook_trigger.py new file mode 100644 index 00000000..12de0a9c --- /dev/null +++ b/agentex/tests/unit/api/test_webhook_trigger.py @@ -0,0 +1,98 @@ +"""Unit tests for the POST /agent_api_keys/webhook-trigger convenience endpoint.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import src.api.routes.agent_api_keys as mod +from fastapi import HTTPException +from src.api.routes.agent_api_keys import create_webhook_trigger +from src.api.schemas.agent_api_keys import CreateWebhookTriggerRequest +from src.domain.entities.agent_api_keys import AgentAPIKeyType + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateWebhookTrigger: + async def _call(self, monkeypatch, request, *, existing=None, base_env=None): + # The agent-authorization helper does real authz work; stub it to a no-op. + monkeypatch.setattr(mod, "_check_agent_or_collapse_to_404", AsyncMock()) + if base_env is not None: + monkeypatch.setenv("AGENTEX_PUBLIC_URL", base_env) + else: + monkeypatch.delenv("AGENTEX_PUBLIC_URL", raising=False) + + agent_use_case = MagicMock() + agent_use_case.get = AsyncMock(return_value=MagicMock(id="agent-1")) + + akuc = MagicMock() + akuc.get_by_agent_id_and_name = AsyncMock(return_value=existing) + akuc.create = AsyncMock( + return_value=MagicMock(id="key-1", api_key_type=request.source) + ) + + resp = await create_webhook_trigger( + request=request, + agent_api_key_use_case=akuc, + agent_use_case=agent_use_case, + authorization_service=MagicMock(), + ) + return resp, akuc + + async def test_creates_key_and_composes_url(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="golden-agent", + source=AgentAPIKeyType.GITHUB, + name="acme/widgets", + forward_path="github-pr/cfg-9", + ) + resp, akuc = await self._call( + monkeypatch, req, base_env="https://sgp.example.com" + ) + + assert len(resp.secret) >= 32 # auto-generated signing secret + assert ( + resp.webhook_url + == "https://sgp.example.com/agents/forward/name/golden-agent/github-pr/cfg-9" + ) + assert resp.webhook_path == "/agents/forward/name/golden-agent/github-pr/cfg-9" + assert resp.source == AgentAPIKeyType.GITHUB + # key registered under the signature-lookup name + type + assert akuc.create.await_args.kwargs["name"] == "acme/widgets" + assert akuc.create.await_args.kwargs["api_key_type"] == AgentAPIKeyType.GITHUB + assert akuc.create.await_args.kwargs["api_key"] == resp.secret + + async def test_uses_provided_secret_and_no_url_without_base(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", + source=AgentAPIKeyType.GITHUB, + name="o/r", + forward_path="gh", + secret="mysecret", + ) + resp, _ = await self._call(monkeypatch, req) + assert resp.secret == "mysecret" + assert resp.webhook_url is None # no AGENTEX_PUBLIC_URL configured + assert resp.webhook_path == "/agents/forward/name/a/gh" + + async def test_conflict_when_key_exists(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", source=AgentAPIKeyType.GITHUB, name="o/r", forward_path="gh" + ) + with pytest.raises(HTTPException) as exc: + await self._call(monkeypatch, req, existing=MagicMock()) + assert exc.value.status_code == 409 + + async def test_rejects_non_webhook_source(self): + req = CreateWebhookTriggerRequest( + agent_name="a", source=AgentAPIKeyType.EXTERNAL, name="x", forward_path="gh" + ) + with pytest.raises(HTTPException) as exc: + await create_webhook_trigger( + request=req, + agent_api_key_use_case=MagicMock(), + agent_use_case=MagicMock(), + authorization_service=MagicMock(), + ) + assert exc.value.status_code == 400 From c3d10a4915875d2a10a761889c4a9c32e7a64f29 Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Mon, 22 Jun 2026 22:03:21 -0400 Subject: [PATCH 2/4] fix(agent_api_keys): require provided secret for Slack triggers Slack signs requests with the app's existing Signing Secret, not a per-webhook secret we can generate. Auto-generating one for a Slack trigger stored a random value that would never match, so every real Slack delivery failed signature verification. Now Slack requires the caller to supply 'secret'; GitHub still auto-generates. Addresses Greptile P1. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex/src/api/routes/agent_api_keys.py | 14 ++++++++++++ .../tests/unit/api/test_webhook_trigger.py | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/agentex/src/api/routes/agent_api_keys.py b/agentex/src/api/routes/agent_api_keys.py index e853ea62..aaf894da 100644 --- a/agentex/src/api/routes/agent_api_keys.py +++ b/agentex/src/api/routes/agent_api_keys.py @@ -141,6 +141,20 @@ async def create_webhook_trigger( detail=f"A {request.source} webhook key named '{request.name}' already exists for this agent.", ) + # GitHub lets you supply (and we can generate) a per-webhook secret to paste into the + # repo's Secret field. Slack is different: it signs every request with the app's own + # Signing Secret, so the caller must supply that exact value — a generated one would + # never match, and validate_slack_delivery_webhook would reject every real delivery. + # (See PR #329 discussion.) + if request.source == AgentAPIKeyType.SLACK and not request.secret: + raise HTTPException( + status_code=400, + detail=( + "Slack triggers must supply 'secret' set to the Slack app's Signing Secret " + "(from your app credentials); it can't be generated." + ), + ) + secret = request.secret or secrets.token_hex(32) agent_api_key_entity = await agent_api_key_use_case.create( agent_id=agent.id, diff --git a/agentex/tests/unit/api/test_webhook_trigger.py b/agentex/tests/unit/api/test_webhook_trigger.py index 12de0a9c..34063d0a 100644 --- a/agentex/tests/unit/api/test_webhook_trigger.py +++ b/agentex/tests/unit/api/test_webhook_trigger.py @@ -96,3 +96,25 @@ async def test_rejects_non_webhook_source(self): authorization_service=MagicMock(), ) assert exc.value.status_code == 400 + + async def test_slack_without_secret_rejected(self, monkeypatch): + # Slack signs with the app's existing Signing Secret — we can't generate one, + # so omitting it must 400 rather than store a random value that never matches. + req = CreateWebhookTriggerRequest( + agent_name="a", source=AgentAPIKeyType.SLACK, name="my-app", forward_path="slack" + ) + with pytest.raises(HTTPException) as exc: + await self._call(monkeypatch, req) + assert exc.value.status_code == 400 + + async def test_slack_with_provided_secret_ok(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", + source=AgentAPIKeyType.SLACK, + name="my-app", + forward_path="slack", + secret="slack-signing-secret", + ) + resp, akuc = await self._call(monkeypatch, req) + assert resp.secret == "slack-signing-secret" + assert akuc.create.await_args.kwargs["api_key"] == "slack-signing-secret" From de85c652f0fb03951baed472750c529496c1de3f Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Tue, 23 Jun 2026 15:58:13 -0400 Subject: [PATCH 3/4] fix(agent_api_keys): harden webhook trigger setup --- agentex/src/api/routes/agent_api_keys.py | 16 ++++++- agentex/src/api/schemas/agent_api_keys.py | 6 ++- .../tests/unit/api/test_webhook_trigger.py | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/agentex/src/api/routes/agent_api_keys.py b/agentex/src/api/routes/agent_api_keys.py index aaf894da..db50a1d1 100644 --- a/agentex/src/api/routes/agent_api_keys.py +++ b/agentex/src/api/routes/agent_api_keys.py @@ -1,5 +1,6 @@ import os import secrets +from urllib.parse import quote from fastapi import APIRouter, HTTPException, Query @@ -38,6 +39,10 @@ ) +def _has_control_chars(value: str) -> bool: + return any(ord(char) < 32 or ord(char) == 127 for char in value) + + @router.post( "", response_model=CreateAPIKeyResponse, @@ -155,7 +160,7 @@ async def create_webhook_trigger( ), ) - secret = request.secret or secrets.token_hex(32) + secret = request.secret if request.secret is not None else secrets.token_hex(32) agent_api_key_entity = await agent_api_key_use_case.create( agent_id=agent.id, api_key=str(secret), @@ -164,7 +169,14 @@ async def create_webhook_trigger( ) forward_path = request.forward_path.lstrip("/") - webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}" + if _has_control_chars(forward_path): + raise HTTPException( + status_code=400, + detail="forward_path must not contain control characters.", + ) + encoded_agent_name = quote(request.agent_name, safe="") + encoded_forward_path = quote(forward_path, safe="/") + webhook_path = f"/agents/forward/name/{encoded_agent_name}/{encoded_forward_path}" base_url = (request.base_url or os.environ.get("AGENTEX_PUBLIC_URL", "")).rstrip( "/" ) diff --git a/agentex/src/api/schemas/agent_api_keys.py b/agentex/src/api/schemas/agent_api_keys.py index 481ae1b3..05f87c28 100644 --- a/agentex/src/api/schemas/agent_api_keys.py +++ b/agentex/src/api/schemas/agent_api_keys.py @@ -76,7 +76,11 @@ class CreateWebhookTriggerRequest(BaseModel): ) secret: str | None = Field( None, - description="Optional signing secret; if unset, one is generated and returned.", + description=( + "Signing secret. For GitHub, omit to generate one, or provide an existing " + "webhook secret. For Slack, this is required and must be the Slack app's " + "Signing Secret." + ), ) base_url: str | None = Field( None, diff --git a/agentex/tests/unit/api/test_webhook_trigger.py b/agentex/tests/unit/api/test_webhook_trigger.py index 34063d0a..7fcddac7 100644 --- a/agentex/tests/unit/api/test_webhook_trigger.py +++ b/agentex/tests/unit/api/test_webhook_trigger.py @@ -76,6 +76,44 @@ async def test_uses_provided_secret_and_no_url_without_base(self, monkeypatch): assert resp.webhook_url is None # no AGENTEX_PUBLIC_URL configured assert resp.webhook_path == "/agents/forward/name/a/gh" + async def test_github_empty_secret_is_preserved(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", + source=AgentAPIKeyType.GITHUB, + name="o/r", + forward_path="gh", + secret="", + ) + resp, akuc = await self._call(monkeypatch, req) + assert resp.secret == "" + assert akuc.create.await_args.kwargs["api_key"] == "" + + async def test_forward_path_reserved_chars_are_url_encoded(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="golden agent", + source=AgentAPIKeyType.GITHUB, + name="o/r", + forward_path="github-pr/cfg-9?mode=review#frag ment", + ) + resp, _ = await self._call(monkeypatch, req, base_env="https://sgp.example.com/") + + assert ( + resp.webhook_path + == "/agents/forward/name/golden%20agent/github-pr/cfg-9%3Fmode%3Dreview%23frag%20ment" + ) + assert resp.webhook_url == f"https://sgp.example.com{resp.webhook_path}" + + async def test_forward_path_control_chars_rejected(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", + source=AgentAPIKeyType.GITHUB, + name="o/r", + forward_path="github-pr/cfg-9\nnext", + ) + with pytest.raises(HTTPException) as exc: + await self._call(monkeypatch, req) + assert exc.value.status_code == 400 + async def test_conflict_when_key_exists(self, monkeypatch): req = CreateWebhookTriggerRequest( agent_name="a", source=AgentAPIKeyType.GITHUB, name="o/r", forward_path="gh" @@ -118,3 +156,11 @@ async def test_slack_with_provided_secret_ok(self, monkeypatch): resp, akuc = await self._call(monkeypatch, req) assert resp.secret == "slack-signing-secret" assert akuc.create.await_args.kwargs["api_key"] == "slack-signing-secret" + + async def test_secret_schema_documents_slack_requirement(self): + description = CreateWebhookTriggerRequest.model_fields["secret"].description + + assert description is not None + assert "For GitHub" in description + assert "For Slack" in description + assert "required" in description From 88fcebdb8b89a3204b72b345b02756484d12c02c Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Tue, 23 Jun 2026 16:02:00 -0400 Subject: [PATCH 4/4] fix(agent_api_keys): validate webhook path before create --- agentex/openapi.yaml | 4 +++- agentex/src/api/routes/agent_api_keys.py | 13 +++++++------ agentex/tests/unit/api/test_webhook_trigger.py | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/agentex/openapi.yaml b/agentex/openapi.yaml index 2a6cf1c3..1ccada2b 100644 --- a/agentex/openapi.yaml +++ b/agentex/openapi.yaml @@ -4615,7 +4615,9 @@ components: - type: string - type: 'null' title: Secret - description: Optional signing secret; if unset, one is generated and returned. + description: Signing secret. For GitHub, omit to generate one, or provide + an existing webhook secret. For Slack, this is required and must be the + Slack app's Signing Secret. base_url: anyOf: - type: string diff --git a/agentex/src/api/routes/agent_api_keys.py b/agentex/src/api/routes/agent_api_keys.py index db50a1d1..74c02895 100644 --- a/agentex/src/api/routes/agent_api_keys.py +++ b/agentex/src/api/routes/agent_api_keys.py @@ -160,6 +160,13 @@ async def create_webhook_trigger( ), ) + forward_path = request.forward_path.lstrip("/") + if _has_control_chars(forward_path): + raise HTTPException( + status_code=400, + detail="forward_path must not contain control characters.", + ) + secret = request.secret if request.secret is not None else secrets.token_hex(32) agent_api_key_entity = await agent_api_key_use_case.create( agent_id=agent.id, @@ -168,12 +175,6 @@ async def create_webhook_trigger( api_key_type=request.source, ) - forward_path = request.forward_path.lstrip("/") - if _has_control_chars(forward_path): - raise HTTPException( - status_code=400, - detail="forward_path must not contain control characters.", - ) encoded_agent_name = quote(request.agent_name, safe="") encoded_forward_path = quote(forward_path, safe="/") webhook_path = f"/agents/forward/name/{encoded_agent_name}/{encoded_forward_path}" diff --git a/agentex/tests/unit/api/test_webhook_trigger.py b/agentex/tests/unit/api/test_webhook_trigger.py index 7fcddac7..ab8fbc22 100644 --- a/agentex/tests/unit/api/test_webhook_trigger.py +++ b/agentex/tests/unit/api/test_webhook_trigger.py @@ -104,15 +104,30 @@ async def test_forward_path_reserved_chars_are_url_encoded(self, monkeypatch): assert resp.webhook_url == f"https://sgp.example.com{resp.webhook_path}" async def test_forward_path_control_chars_rejected(self, monkeypatch): + monkeypatch.setattr(mod, "_check_agent_or_collapse_to_404", AsyncMock()) + req = CreateWebhookTriggerRequest( agent_name="a", source=AgentAPIKeyType.GITHUB, name="o/r", forward_path="github-pr/cfg-9\nnext", ) + agent_use_case = MagicMock() + agent_use_case.get = AsyncMock(return_value=MagicMock(id="agent-1")) + + akuc = MagicMock() + akuc.get_by_agent_id_and_name = AsyncMock(return_value=None) + akuc.create = AsyncMock() + with pytest.raises(HTTPException) as exc: - await self._call(monkeypatch, req) + await create_webhook_trigger( + request=req, + agent_api_key_use_case=akuc, + agent_use_case=agent_use_case, + authorization_service=MagicMock(), + ) assert exc.value.status_code == 400 + akuc.create.assert_not_awaited() async def test_conflict_when_key_exists(self, monkeypatch): req = CreateWebhookTriggerRequest(