feat(api): add REST API endpoints for repository webhook delivery history (#38775)

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>
This commit is contained in:
authored and GitHub committed 2026-10-24 00:18:51 +00:00
1 parent bcb4559c10
commit 96bee6c5a5
11 files changed
+824 -6

No files matched your search

+27 -6
View File
@@ -19,6 +19,19 @@ import (
"xorm.io/builder"
)
type ListHookTaskOptions struct {
db.ListOptions
HookID int64
}
func (opts ListHookTaskOptions) ToConds() builder.Cond {
return builder.Eq{"hook_task.hook_id": opts.HookID}
}
func (opts ListHookTaskOptions) ToOrders() string {
return "hook_task.id DESC"
}
// ___ ___ __ ___________ __
// / | \ ____ ____ | | _\__ ___/____ _____| | __
// / ~ \/ _ \ / _ \| |/ / | | \__ \ / ___/ |/ /
@@ -116,6 +129,19 @@ func HookTasks(ctx context.Context, hookID int64, page int) ([]*HookTask, error)
Find(&tasks)
}
func GetHookTaskByUUID(ctx context.Context, hookID int64, uuid string) (*HookTask, error) {
task, exist, err := db.Get[HookTask](ctx, builder.Eq{"hook_id": hookID, "uuid": uuid})
if err != nil {
return nil, err
} else if !exist {
return nil, ErrHookTaskNotExist{
HookID: hookID,
UUID: uuid,
}
}
return task, nil
}
// CreateHookTask creates a new hook task,
// it handles conversion from Payload to PayloadContent.
func CreateHookTask(ctx context.Context, t *HookTask) (*HookTask, error) {
@@ -152,14 +178,9 @@ func UpdateHookTask(ctx context.Context, t *HookTask) error {
// ReplayHookTask copies a hook task to get re-delivered
func ReplayHookTask(ctx context.Context, hookID int64, uuid string) (*HookTask, error) {
task, exist, err := db.Get[HookTask](ctx, builder.Eq{"hook_id": hookID, "uuid": uuid})
task, err := GetHookTaskByUUID(ctx, hookID, uuid)
if err != nil {
return nil, err
} else if !exist {
return nil, ErrHookTaskNotExist{
HookID: hookID,
UUID: uuid,
}
}
return CreateHookTask(ctx, &HookTask{
+17
View File
@@ -277,6 +277,23 @@ func TestHookTasks(t *testing.T) {
hookTasks, err = HookTasks(t.Context(), unittest.NonexistentID, 1)
assert.NoError(t, err)
assert.Empty(t, hookTasks)
tasks, count, err := db.FindAndCount[HookTask](t.Context(), ListHookTaskOptions{HookID: hook.ID})
assert.NoError(t, err)
assert.EqualValues(t, 3, count)
require.Len(t, tasks, 3)
task, err := GetHookTaskByUUID(t.Context(), hook.ID, tasks[0].UUID)
require.NoError(t, err)
assert.Equal(t, tasks[0].ID, task.ID)
tasks, count, err = db.FindAndCount[HookTask](t.Context(), ListHookTaskOptions{HookID: unittest.NonexistentID})
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
assert.Empty(t, tasks)
_, err = GetHookTaskByUUID(t.Context(), hook.ID, "non-existent-uuid")
assert.True(t, IsErrHookTaskNotExist(err))
}
func TestCreateHookTask(t *testing.T) {
+44
View File
@@ -46,6 +46,50 @@ type Hook struct {
// HookList represents a list of API hook.
type HookList []*Hook
// HookDeliveryRequest represents the request information of a webhook delivery
type HookDeliveryRequest struct {
// The URL the delivery was sent to
URL string `json:"url"`
// The HTTP method used for the delivery
HTTPMethod string `json:"http_method"`
// The headers sent with the delivery
Headers map[string]string `json:"headers"`
// The body sent with the delivery
Body string `json:"body"`
}
// HookDeliveryResponse represents the response information of a webhook delivery
type HookDeliveryResponse struct {
// The HTTP status code of the response
Status int `json:"status"`
// The headers received in the response
Headers map[string]string `json:"headers"`
// The body received in the response
Body string `json:"body"`
}
// HookDelivery represents a single delivery attempt for a webhook
type HookDelivery struct {
// The unique identifier of the delivery
UUID string `json:"uuid"`
// The event type that triggered this delivery
EventType string `json:"event_type"`
// Whether the delivery has been sent
IsDelivered bool `json:"is_delivered"`
// Whether the delivery was successful (2xx response)
IsSucceeded bool `json:"is_succeeded"`
// swagger:strfmt date-time
// The date and time when the delivery was sent
Delivered *time.Time `json:"delivered_at"`
// The request that was sent
Request *HookDeliveryRequest `json:"request"`
// The response that was received
Response *HookDeliveryResponse `json:"response"`
}
// HookDeliveryList represents a list of API hook deliveries.
type HookDeliveryList []*HookDelivery
// CreateHookOptionConfig has all config options in it
// required are "content_type" and "url" Required
type CreateHookOptionConfig map[string]string
+4
View File
@@ -1281,6 +1281,10 @@ func Routes() *web.Router {
Patch(bind(api.EditHookOption{}), repo.EditHook).
Delete(repo.DeleteHook)
m.Post("/tests", context.ReferencesGitRepo(), context.RepoRefForAPI, repo.TestHook)
m.Group("/deliveries", func() {
m.Get("", repo.ListDeliveries)
m.Get("/{uuid}", repo.GetDelivery)
})
})
}, reqToken(), reqAdmin(), reqWebhooksEnabled())
m.Group("/collaborators", func() {
+122
View File
@@ -122,6 +122,128 @@ func GetHook(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, apiHook)
}
func ListDeliveries(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/hooks/{id}/deliveries repository repoListHookDeliveries
// ---
// summary: List the deliveries of a hook in a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: id
// in: path
// description: id of the hook
// type: integer
// format: int64
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/HookDeliveryList"
// "404":
// "$ref": "#/responses/notFound"
hookID := ctx.PathParamInt64("id")
if _, err := utils.GetRepoHook(ctx, ctx.Repo.Repository.ID, hookID); err != nil {
return
}
listOptions := utils.GetListOptions(ctx)
tasks, count, err := db.FindAndCount[webhook.HookTask](ctx, webhook.ListHookTaskOptions{
ListOptions: listOptions,
HookID: hookID,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
deliveries := make([]*api.HookDelivery, len(tasks))
for i, task := range tasks {
delivery := webhook_service.ToHookDelivery(task)
if delivery.Request != nil { // the list omits payloads, they are only returned per delivery
delivery.Request.Headers = nil
delivery.Request.Body = ""
}
if delivery.Response != nil {
delivery.Response.Headers = nil
delivery.Response.Body = ""
}
deliveries[i] = delivery
}
ctx.SetLinkHeader(count, listOptions.PageSize)
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, deliveries)
}
func GetDelivery(ctx *context.APIContext) {
// swagger:operation GET /repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid} repository repoGetHookDelivery
// ---
// summary: Get a delivery of a hook in a repository
// produces:
// - application/json
// parameters:
// - name: owner
// in: path
// description: owner of the repo
// type: string
// required: true
// - name: repo
// in: path
// description: name of the repo
// type: string
// required: true
// - name: id
// in: path
// description: id of the hook
// type: integer
// format: int64
// required: true
// - name: uuid
// in: path
// description: uuid of the delivery
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/HookDelivery"
// "404":
// "$ref": "#/responses/notFound"
hookID := ctx.PathParamInt64("id")
if _, err := utils.GetRepoHook(ctx, ctx.Repo.Repository.ID, hookID); err != nil {
return
}
task, err := webhook.GetHookTaskByUUID(ctx, hookID, ctx.PathParam("uuid"))
if err != nil {
if webhook.IsErrHookTaskNotExist(err) {
ctx.APIErrorNotFound()
} else {
ctx.APIErrorInternal(err)
}
return
}
ctx.JSON(http.StatusOK, webhook_service.ToHookDelivery(task))
}
// TestHook tests a hook
func TestHook(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/hooks/{id}/tests repository repoTestHook
+14
View File
@@ -105,6 +105,20 @@ type swaggerResponseHookList struct {
Body []api.Hook `json:"body"`
}
// HookDelivery
// swagger:response HookDelivery
type swaggerResponseHookDelivery struct {
// in:body
Body api.HookDelivery `json:"body"`
}
// HookDeliveryList
// swagger:response HookDeliveryList
type swaggerResponseHookDeliveryList struct {
// in:body
Body []api.HookDelivery `json:"body"`
}
// GitHook
// swagger:response GitHook
type swaggerResponseGitHook struct {
+37
View File
@@ -6,6 +6,7 @@ package webhook
import (
"fmt"
"html"
"maps"
"net/url"
"strings"
@@ -461,3 +462,39 @@ func ToHook(repoLink string, w *webhook_model.Webhook) (*api.Hook, error) {
BranchFilter: w.BranchFilter,
}, nil
}
func ToHookDelivery(t *webhook_model.HookTask) *api.HookDelivery {
delivery := &api.HookDelivery{
UUID: t.UUID,
EventType: string(t.EventType),
IsDelivered: t.IsDelivered,
IsSucceeded: t.IsSucceed,
}
if t.IsDelivered {
delivery.Delivered = new(t.Delivered.AsTime())
}
if t.RequestInfo != nil {
delivery.Request = &api.HookDeliveryRequest{
URL: t.RequestInfo.URL,
HTTPMethod: t.RequestInfo.HTTPMethod,
Headers: maps.Clone(t.RequestInfo.Headers),
Body: t.RequestInfo.Body,
}
for name := range delivery.Request.Headers {
if strings.EqualFold(name, "Authorization") {
delivery.Request.Headers[name] = "******"
}
}
if t.PayloadVersion == 1 && delivery.Request.Body == "" {
delivery.Request.Body = t.PayloadContent
}
}
if t.ResponseInfo != nil {
delivery.Response = &api.HookDeliveryResponse{
Status: t.ResponseInfo.Status,
Headers: t.ResponseInfo.Headers,
Body: t.ResponseInfo.Body,
}
}
return delivery
}
+36
View File
@@ -6,11 +6,47 @@ package webhook
import (
"testing"
webhook_model "gitea.dev/models/webhook"
api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToHookDelivery(t *testing.T) {
for _, test := range []struct {
name string
payloadVersion int
requestContent string
body string
}{
{"legacy payload", 1, `{"headers":{"Authorization":"stored credential","authorization":"lowercase credential","Content-Type":"application/json"}}`, "payload"},
{"recorded body", 1, `{"body":"sent body"}`, "sent body"},
{"empty current body", 2, `{}`, ""},
} {
t.Run(test.name, func(t *testing.T) {
task := &webhook_model.HookTask{
PayloadVersion: test.payloadVersion,
PayloadContent: "payload",
RequestContent: test.requestContent,
}
task.AfterLoad()
delivery := ToHookDelivery(task)
require.NotNil(t, delivery.Request)
assert.Equal(t, test.body, delivery.Request.Body)
if task.RequestInfo.Headers != nil {
assert.Equal(t, map[string]string{
"Authorization": "******",
"authorization": "******",
"Content-Type": "application/json",
}, delivery.Request.Headers)
assert.Equal(t, "stored credential", task.RequestInfo.Headers["Authorization"])
assert.Equal(t, "lowercase credential", task.RequestInfo.Headers["authorization"])
}
})
}
}
func createTestPayload() *api.CreatePayload {
return &api.CreatePayload{
Sha: "2020558fe2e34debb818a514715839cabd25e777",
+238
View File
@@ -624,6 +624,29 @@
},
"description": "Hook"
},
"HookDelivery": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HookDelivery"
}
}
},
"description": "HookDelivery"
},
"HookDeliveryList": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/HookDelivery"
},
"type": "array"
}
}
},
"description": "HookDeliveryList"
},
"HookList": {
"content": {
"application/json": {
@@ -7248,6 +7271,102 @@
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"HookDelivery": {
"description": "HookDelivery represents a single delivery attempt for a webhook",
"properties": {
"delivered_at": {
"description": "The date and time when the delivery was sent",
"format": "date-time",
"type": "string",
"x-go-name": "Delivered"
},
"event_type": {
"description": "The event type that triggered this delivery",
"type": "string",
"x-go-name": "EventType"
},
"is_delivered": {
"description": "Whether the delivery has been sent",
"type": "boolean",
"x-go-name": "IsDelivered"
},
"is_succeeded": {
"description": "Whether the delivery was successful (2xx response)",
"type": "boolean",
"x-go-name": "IsSucceeded"
},
"request": {
"$ref": "#/components/schemas/HookDeliveryRequest"
},
"response": {
"$ref": "#/components/schemas/HookDeliveryResponse"
},
"uuid": {
"description": "The unique identifier of the delivery",
"type": "string",
"x-go-name": "UUID"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryRequest": {
"description": "HookDeliveryRequest represents the request information of a webhook delivery",
"properties": {
"body": {
"description": "The body sent with the delivery",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"description": "The headers sent with the delivery",
"type": "object",
"x-go-name": "Headers"
},
"http_method": {
"description": "The HTTP method used for the delivery",
"type": "string",
"x-go-name": "HTTPMethod"
},
"url": {
"description": "The URL the delivery was sent to",
"format": "uri",
"type": "string",
"x-go-name": "URL"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryResponse": {
"description": "HookDeliveryResponse represents the response information of a webhook delivery",
"properties": {
"body": {
"description": "The body received in the response",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"description": "The headers received in the response",
"type": "object",
"x-go-name": "Headers"
},
"status": {
"description": "The HTTP status code of the response",
"format": "int64",
"type": "integer",
"x-go-name": "Status"
}
},
"type": "object",
"x-go-package": "gitea.dev/modules/structs"
},
"Identity": {
"description": "Identity for a person's identity like an author or committer",
"properties": {
@@ -23104,6 +23223,125 @@
]
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries": {
"get": {
"operationId": "repoListHookDeliveries",
"parameters": [
{
"description": "owner of the repo",
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "name of the repo",
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "id of the hook",
"in": "path",
"name": "id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"description": "page number of results to return (1-based)",
"in": "query",
"name": "page",
"schema": {
"type": "integer"
}
},
{
"description": "page size of results",
"in": "query",
"name": "limit",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/HookDeliveryList"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "List the deliveries of a hook in a repository",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}": {
"get": {
"operationId": "repoGetHookDelivery",
"parameters": [
{
"description": "owner of the repo",
"in": "path",
"name": "owner",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "name of the repo",
"in": "path",
"name": "repo",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "id of the hook",
"in": "path",
"name": "id",
"required": true,
"schema": {
"format": "int64",
"type": "integer"
}
},
{
"description": "uuid of the delivery",
"in": "path",
"name": "uuid",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"$ref": "#/components/responses/HookDelivery"
},
"404": {
"$ref": "#/components/responses/notFound"
}
},
"summary": "Get a delivery of a hook in a repository",
"tags": [
"repository"
]
}
},
"/repos/{owner}/{repo}/hooks/{id}/tests": {
"post": {
"operationId": "repoTestHook",
+217
View File
@@ -10997,6 +10997,113 @@
}
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "List the deliveries of a hook in a repository",
"operationId": "repoListHookDeliveries",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "id of the hook",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/HookDeliveryList"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/hooks/{id}/deliveries/{uuid}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"repository"
],
"summary": "Get a delivery of a hook in a repository",
"operationId": "repoGetHookDelivery",
"parameters": [
{
"type": "string",
"description": "owner of the repo",
"name": "owner",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repo",
"name": "repo",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "id of the hook",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "uuid of the delivery",
"name": "uuid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/HookDelivery"
},
"404": {
"$ref": "#/responses/notFound"
}
}
}
},
"/repos/{owner}/{repo}/hooks/{id}/tests": {
"post": {
"produces": [
@@ -31083,6 +31190,101 @@
},
"x-go-package": "gitea.dev/modules/structs"
},
"HookDelivery": {
"description": "HookDelivery represents a single delivery attempt for a webhook",
"type": "object",
"properties": {
"delivered_at": {
"description": "The date and time when the delivery was sent",
"type": "string",
"format": "date-time",
"x-go-name": "Delivered"
},
"event_type": {
"description": "The event type that triggered this delivery",
"type": "string",
"x-go-name": "EventType"
},
"is_delivered": {
"description": "Whether the delivery has been sent",
"type": "boolean",
"x-go-name": "IsDelivered"
},
"is_succeeded": {
"description": "Whether the delivery was successful (2xx response)",
"type": "boolean",
"x-go-name": "IsSucceeded"
},
"request": {
"$ref": "#/definitions/HookDeliveryRequest"
},
"response": {
"$ref": "#/definitions/HookDeliveryResponse"
},
"uuid": {
"description": "The unique identifier of the delivery",
"type": "string",
"x-go-name": "UUID"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryRequest": {
"description": "HookDeliveryRequest represents the request information of a webhook delivery",
"type": "object",
"properties": {
"body": {
"description": "The body sent with the delivery",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"description": "The headers sent with the delivery",
"type": "object",
"additionalProperties": {
"type": "string"
},
"x-go-name": "Headers"
},
"http_method": {
"description": "The HTTP method used for the delivery",
"type": "string",
"x-go-name": "HTTPMethod"
},
"url": {
"description": "The URL the delivery was sent to",
"type": "string",
"x-go-name": "URL"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"HookDeliveryResponse": {
"description": "HookDeliveryResponse represents the response information of a webhook delivery",
"type": "object",
"properties": {
"body": {
"description": "The body received in the response",
"type": "string",
"x-go-name": "Body"
},
"headers": {
"description": "The headers received in the response",
"type": "object",
"additionalProperties": {
"type": "string"
},
"x-go-name": "Headers"
},
"status": {
"description": "The HTTP status code of the response",
"type": "integer",
"format": "int64",
"x-go-name": "Status"
}
},
"x-go-package": "gitea.dev/modules/structs"
},
"Identity": {
"description": "Identity for a person's identity like an author or committer",
"type": "object",
@@ -35495,6 +35697,21 @@
"$ref": "#/definitions/Hook"
}
},
"HookDelivery": {
"description": "HookDelivery",
"schema": {
"$ref": "#/definitions/HookDelivery"
}
},
"HookDeliveryList": {
"description": "HookDeliveryList",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/HookDelivery"
}
}
},
"HookList": {
"description": "HookList",
"schema": {
+68
View File
@@ -12,10 +12,13 @@ import (
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) {
@@ -96,3 +99,68 @@ func TestAPICreateHook(t *testing.T) {
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)
}
}