mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-26 06:16:56 +09:00
Webhook delivery history (request/response, status, timestamp) was only
reachable through the repo settings UI, which shows the last ~10
deliveries with no filtering. There was no way to fetch this data
programmatically for debugging integrations or auditing webhook
activity.
This adds two read endpoints at the repository level:
- `GET /repos/{owner}/{repo}/hooks/{id}/deliveries` — paginated list of
a hook's deliveries, most recent first
- `GET /repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}` — a single
delivery, including request/response headers, body, and status
Both reuse the existing `hook_task` storage and retention policy (the
`CleanupHookTaskTable` cron job) that already back the "Recent
Deliveries" web UI, and are gated by the same `reqAdmin()` +
`reqWebhooksEnabled()` guard as the existing hook endpoints.
Org, user, and admin/system-level webhooks are intentionally out of
scope for this PR to keep it reviewable; they can follow as separate
PRs using the same pattern.
## Usage
```bash
curl -H "Authorization: token $TOKEN" \
https://gitea.example.com/api/v1/repos/{owner}/{repo}/hooks/{id}/deliveries
curl -H "Authorization: token $TOKEN" \
https://gitea.example.com/api/v1/repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}
```
## Testing
- Unit tests for the new model-layer query functions
(`ListHookTasks`, `GetHookTaskByUUID`)
- Integration tests covering: list + get, an undelivered task's response
is `null`, a hook/delivery UUID from another repository 404s instead
of leaking across repos, and pagination (`page`/`limit`, `Link` and
`X-Total-Count` headers, correct ordering/no overlap across pages)
- `make lint-go`, `make generate-swagger`
Fixes: https://github.com/go-gitea/gitea/issues/38463.
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
167 lines
6.7 KiB
Go
167 lines
6.7 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
auth_model "gitea.dev/models/auth"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unittest"
|
|
user_model "gitea.dev/models/user"
|
|
webhook_model "gitea.dev/models/webhook"
|
|
api "gitea.dev/modules/structs"
|
|
webhook_module "gitea.dev/modules/webhook"
|
|
"gitea.dev/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAPICreateHook(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
// user1 is an admin user
|
|
session := loginUser(t, "user1")
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/%s", owner.Name, repo.Name, "hooks"), api.CreateHookOption{
|
|
Type: "gitea",
|
|
Config: api.CreateHookOptionConfig{
|
|
"content_type": "json",
|
|
"url": "http://example.com/",
|
|
},
|
|
AuthorizationHeader: "Bearer s3cr3t",
|
|
Name: " CI notifications ",
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
apiHook := DecodeJSON(t, resp, &api.Hook{})
|
|
assert.Equal(t, "http://example.com/", apiHook.Config["url"])
|
|
// the stored authorization header is a secret and must never be returned by the API
|
|
assert.Empty(t, apiHook.AuthorizationHeader)
|
|
assert.Equal(t, "CI notifications", apiHook.Name)
|
|
|
|
// a read-scoped token must not be able to read back the authorization header
|
|
readToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
|
|
getReq := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, apiHook.ID)).
|
|
AddTokenAuth(readToken)
|
|
getResp := MakeRequest(t, getReq, http.StatusOK)
|
|
assert.NotContains(t, getResp.Body.String(), "s3cr3t")
|
|
|
|
newName := "Deploy hook"
|
|
patchReq := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, apiHook.ID), api.EditHookOption{
|
|
Name: &newName,
|
|
}).AddTokenAuth(token)
|
|
patchResp := MakeRequest(t, patchReq, http.StatusOK)
|
|
patched := DecodeJSON(t, patchResp, &api.Hook{})
|
|
assert.Equal(t, newName, patched.Name)
|
|
|
|
hooksURL := fmt.Sprintf("/api/v1/repos/%s/%s/hooks", owner.Name, repo.Name)
|
|
|
|
// Create with Name field omitted: Name should be ""
|
|
req2 := NewRequestWithJSON(t, "POST", hooksURL, api.CreateHookOption{
|
|
Type: "gitea",
|
|
Config: api.CreateHookOptionConfig{
|
|
"content_type": "json",
|
|
"url": "http://example.com/",
|
|
},
|
|
}).AddTokenAuth(token)
|
|
resp2 := MakeRequest(t, req2, http.StatusCreated)
|
|
created := DecodeJSON(t, resp2, &api.Hook{})
|
|
assert.Empty(t, created.Name)
|
|
|
|
hookURL := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d", owner.Name, repo.Name, created.ID)
|
|
|
|
// PATCH with Name set: existing Name must be updated
|
|
setName := "original"
|
|
setReq := NewRequestWithJSON(t, "PATCH", hookURL, api.EditHookOption{
|
|
Name: &setName,
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, setReq, http.StatusOK)
|
|
|
|
// PATCH without Name field: name must remain "original"
|
|
patchReq2 := NewRequestWithJSON(t, "PATCH", hookURL, api.EditHookOption{}).AddTokenAuth(token)
|
|
patchResp2 := MakeRequest(t, patchReq2, http.StatusOK)
|
|
notCleared := DecodeJSON(t, patchResp2, &api.Hook{})
|
|
assert.Equal(t, "original", notCleared.Name)
|
|
|
|
// PATCH with Name: "" explicitly: Name should be cleared to ""
|
|
clearReq := NewRequestWithJSON(t, "PATCH", hookURL, api.EditHookOption{
|
|
Name: new(""),
|
|
}).AddTokenAuth(token)
|
|
clearResp := MakeRequest(t, clearReq, http.StatusOK)
|
|
cleared := DecodeJSON(t, clearResp, &api.Hook{})
|
|
assert.Empty(t, cleared.Name)
|
|
}
|
|
|
|
func TestAPIListAndGetRepoHookDeliveries(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37})
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
|
|
|
session := loginUser(t, "user1")
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
|
|
hook := DecodeJSON(t, MakeRequest(t, NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/hooks", owner.Name, repo.Name), api.CreateHookOption{
|
|
Type: "gitea",
|
|
Config: api.CreateHookOptionConfig{
|
|
"content_type": "json",
|
|
"url": "http://example.com/",
|
|
},
|
|
}).AddTokenAuth(token), http.StatusCreated), &api.Hook{})
|
|
|
|
tasks := make([]*webhook_model.HookTask, 0, 4)
|
|
for i := range 4 {
|
|
task, err := webhook_model.CreateHookTask(t.Context(), &webhook_model.HookTask{
|
|
HookID: hook.ID,
|
|
EventType: webhook_module.HookEventPush,
|
|
PayloadVersion: 2,
|
|
IsDelivered: i < 3,
|
|
IsSucceed: i < 3,
|
|
})
|
|
require.NoError(t, err)
|
|
tasks = append(tasks, task)
|
|
}
|
|
|
|
deliveriesURL := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d/deliveries", owner.Name, repo.Name, hook.ID)
|
|
deliveries := DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", deliveriesURL).AddTokenAuth(token), http.StatusOK), &api.HookDeliveryList{})
|
|
require.Len(t, *deliveries, 4)
|
|
assert.Equal(t, tasks[3].UUID, (*deliveries)[0].UUID)
|
|
assert.False(t, (*deliveries)[0].IsDelivered)
|
|
assert.Nil(t, (*deliveries)[0].Response)
|
|
assert.Nil(t, (*deliveries)[0].Delivered)
|
|
|
|
for page, expectedUUIDs := range [][]string{{tasks[3].UUID, tasks[2].UUID}, {tasks[1].UUID, tasks[0].UUID}} {
|
|
t.Run(fmt.Sprintf("page_%d", page+1), func(t *testing.T) {
|
|
resp := MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("%s?limit=2&page=%d", deliveriesURL, page+1)).AddTokenAuth(token), http.StatusOK)
|
|
deliveries := DecodeJSON(t, resp, &api.HookDeliveryList{})
|
|
require.Len(t, *deliveries, len(expectedUUIDs))
|
|
assert.Equal(t, "4", resp.Header().Get("X-Total-Count"))
|
|
for i, uuid := range expectedUUIDs {
|
|
assert.Equal(t, uuid, (*deliveries)[i].UUID)
|
|
}
|
|
})
|
|
}
|
|
|
|
delivery := DecodeJSON(t, MakeRequest(t, NewRequest(t, "GET", deliveriesURL+"/"+tasks[0].UUID).AddTokenAuth(token), http.StatusOK), &api.HookDelivery{})
|
|
assert.Equal(t, tasks[0].UUID, delivery.UUID)
|
|
assert.True(t, delivery.IsDelivered)
|
|
assert.True(t, delivery.IsSucceeded)
|
|
assert.NotNil(t, delivery.Delivered)
|
|
|
|
MakeRequest(t, NewRequest(t, "GET", deliveriesURL+"/does-not-exist").AddTokenAuth(token), http.StatusNotFound)
|
|
|
|
otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
otherOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: otherRepo.OwnerID})
|
|
for _, suffix := range []string{"", "/" + tasks[0].UUID} {
|
|
MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/hooks/%d/deliveries%s", otherOwner.Name, otherRepo.Name, hook.ID, suffix)).
|
|
AddTokenAuth(token), http.StatusNotFound)
|
|
}
|
|
}
|