mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Fixtures asserted against try.gitea.io, gitea.io and repositories under upstream's accounts. They now use reserved example hosts and an example owner, the recorded downloader responses were renamed to match, and the RPM and Alpine package fixtures were rebuilt with recomputed digests. The schema dumps the migration test restores are named for the version they capture. Two fixtures were wrong, not merely branded. readme-test carried a special-subdir-.gitea branch while the README lookup and its test both read .hanzo, so that case asserted against a branch that did not exist; the branch now holds .hanzo/README.md. The repository fixtures' delegate hooks still re-exec the pre-rename binary name, and the licenses end-to-end test looked for a module this tree no longer depends on. Co-authored-by: Hanzo Dev <[email protected]>
405 lines
12 KiB
Go
405 lines
12 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package webhook
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/git/models/unittest"
|
|
webhook_model "github.com/hanzoai/git/models/webhook"
|
|
"github.com/hanzoai/git/modules/hostmatcher"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/util"
|
|
webhook_module "github.com/hanzoai/git/modules/webhook"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestWebhookProxy(t *testing.T) {
|
|
oldWebhook := setting.Webhook
|
|
t.Cleanup(func() {
|
|
setting.Webhook = oldWebhook
|
|
})
|
|
|
|
setting.Webhook.ProxyURL = "http://localhost:8080"
|
|
setting.Webhook.ProxyURLFixed, _ = url.Parse(setting.Webhook.ProxyURL)
|
|
setting.Webhook.ProxyHosts = []string{"*.discordapp.com", "discordapp.com"}
|
|
|
|
allowedHostMatcher := hostmatcher.ParseHostMatchList("webhook.ALLOWED_HOST_LIST", "discordapp.com,s.discordapp.com")
|
|
|
|
tests := []struct {
|
|
req string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
req: "https://discordapp.com/api/webhooks/xxxxxxxxx/xxxxxxxxxxxxxxxxxxx",
|
|
want: "http://localhost:8080",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
req: "http://s.discordapp.com/assets/xxxxxx",
|
|
want: "http://localhost:8080",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
req: "http://github.com/a/b",
|
|
want: "",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
req: "http://www.discordapp.com/assets/xxxxxx",
|
|
want: "",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.req, func(t *testing.T) {
|
|
req, err := http.NewRequest(http.MethodPost, tt.req, nil)
|
|
require.NoError(t, err)
|
|
|
|
u, err := webhookProxy(allowedHostMatcher)(req)
|
|
if tt.wantErr {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
|
|
assert.NoError(t, err)
|
|
|
|
got := ""
|
|
if u != nil {
|
|
got = u.String()
|
|
}
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWebhookDeliverGitHeaders(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
done := make(chan struct{}, 1)
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// X-Git-* is our own family; the vendor families must carry the same values
|
|
// so receivers written for those servers keep working.
|
|
assert.Equal(t, "push", r.Header.Get("X-Git-Event"))
|
|
assert.Equal(t, "push", r.Header.Get("X-Git-Event-Type"))
|
|
assert.NotEmpty(t, r.Header.Get("X-Git-Delivery"))
|
|
assert.Equal(t, "repository", r.Header.Get("X-Git-Hook-Installation-Target-Type"))
|
|
for _, h := range []string{"Delivery", "Event", "Event-Type"} {
|
|
assert.Equal(t, r.Header.Get("X-Gogs-"+h), r.Header.Get("X-Git-"+h), "X-Gogs-%s must match X-Git-%s", h, h)
|
|
}
|
|
for _, h := range []string{"Delivery", "Event", "Event-Type", "Hook-Installation-Target-Type"} {
|
|
assert.Equal(t, r.Header.Get("X-GitHub-"+h), r.Header.Get("X-Git-"+h), "X-GitHub-%s must match X-Git-%s", h, h)
|
|
}
|
|
// Signed: the signature header must be present and identical across families.
|
|
assert.NotEmpty(t, r.Header.Get("X-Git-Signature"))
|
|
assert.Equal(t, r.Header.Get("X-Gogs-Signature"), r.Header.Get("X-Git-Signature"))
|
|
assert.Equal(t, "sha256="+r.Header.Get("X-Git-Signature"), r.Header.Get("X-Hub-Signature-256"))
|
|
// We do not name ourselves after the server we forked from.
|
|
for name := range r.Header {
|
|
assert.NotContains(t, strings.ToLower(name), "gitea", "no delivery header may be branded upstream")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
done <- struct{}{}
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
|
|
hook := &webhook_model.Webhook{
|
|
RepoID: 3,
|
|
URL: s.URL + "/webhook",
|
|
ContentType: webhook_model.ContentTypeJSON,
|
|
IsActive: true,
|
|
Type: webhook_module.NATIVE,
|
|
Secret: "s3cr3t",
|
|
}
|
|
assert.NoError(t, webhook_model.CreateWebhook(t.Context(), hook))
|
|
|
|
hookTask := &webhook_model.HookTask{
|
|
HookID: hook.ID,
|
|
EventType: webhook_module.HookEventPush,
|
|
PayloadVersion: 2,
|
|
}
|
|
hookTask, err := webhook_model.CreateHookTask(t.Context(), hookTask)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, hookTask)
|
|
|
|
assert.NoError(t, Deliver(t.Context(), hookTask))
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("waited to long for request to happen")
|
|
}
|
|
assert.True(t, hookTask.IsSucceed)
|
|
}
|
|
|
|
func TestWebhookDeliverAuthorizationHeader(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
done := make(chan struct{}, 1)
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/webhook", r.URL.Path)
|
|
assert.Equal(t, "Bearer s3cr3t-t0ken", r.Header.Get("Authorization"))
|
|
w.WriteHeader(http.StatusOK)
|
|
done <- struct{}{}
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
|
|
hook := &webhook_model.Webhook{
|
|
RepoID: 3,
|
|
URL: s.URL + "/webhook",
|
|
ContentType: webhook_model.ContentTypeJSON,
|
|
IsActive: true,
|
|
Type: webhook_module.NATIVE,
|
|
}
|
|
err := hook.SetHeaderAuthorization("Bearer s3cr3t-t0ken")
|
|
assert.NoError(t, err)
|
|
assert.NoError(t, webhook_model.CreateWebhook(t.Context(), hook))
|
|
|
|
hookTask := &webhook_model.HookTask{
|
|
HookID: hook.ID,
|
|
EventType: webhook_module.HookEventPush,
|
|
PayloadVersion: 2,
|
|
}
|
|
|
|
hookTask, err = webhook_model.CreateHookTask(t.Context(), hookTask)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, hookTask)
|
|
|
|
assert.NoError(t, Deliver(t.Context(), hookTask))
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("waited to long for request to happen")
|
|
}
|
|
|
|
assert.True(t, hookTask.IsSucceed)
|
|
assert.Equal(t, "******", hookTask.RequestInfo.Headers["Authorization"])
|
|
}
|
|
|
|
func TestWebhookDeliverHookTask(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
done := make(chan struct{}, 1)
|
|
version2Body := `{
|
|
"body": "[[test/repo](http://localhost:3000/test/repo)] user1 pushed 2 commits to [test](http://localhost:3000/test/repo/src/branch/test):\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1\n[2020558](http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778): commit message - user1",
|
|
"msgtype": "",
|
|
"format": "org.matrix.custom.html",
|
|
"formatted_body": "[<a href=\"http://localhost:3000/test/repo\">test/repo</a>] user1 pushed 2 commits to <a href=\"http://localhost:3000/test/repo/src/branch/test\">test</a>:<br><a href=\"http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778\">2020558</a>: commit message - user1<br><a href=\"http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778\">2020558</a>: commit message - user1",
|
|
"ai.hanzo.commits": [
|
|
{
|
|
"id": "2020558fe2e34debb818a514715839cabd25e778",
|
|
"message": "commit message",
|
|
"url": "http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778",
|
|
"author": {
|
|
"name": "user1",
|
|
"email": "user1@localhost",
|
|
"username": "user1"
|
|
},
|
|
"committer": {
|
|
"name": "user1",
|
|
"email": "user1@localhost",
|
|
"username": "user1"
|
|
},
|
|
"verification": null,
|
|
"timestamp": "0001-01-01T00:00:00Z",
|
|
"added": null,
|
|
"removed": null,
|
|
"modified": null
|
|
},
|
|
{
|
|
"id": "2020558fe2e34debb818a514715839cabd25e778",
|
|
"message": "commit message",
|
|
"url": "http://localhost:3000/test/repo/commit/2020558fe2e34debb818a514715839cabd25e778",
|
|
"author": {
|
|
"name": "user1",
|
|
"email": "user1@localhost",
|
|
"username": "user1"
|
|
},
|
|
"committer": {
|
|
"name": "user1",
|
|
"email": "user1@localhost",
|
|
"username": "user1"
|
|
},
|
|
"verification": null,
|
|
"timestamp": "0001-01-01T00:00:00Z",
|
|
"added": null,
|
|
"removed": null,
|
|
"modified": null
|
|
}
|
|
]
|
|
}`
|
|
|
|
testVersion := 0
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "PUT", r.Method)
|
|
assert.True(t, strings.HasPrefix(r.URL.Path, "/webhook/"))
|
|
assert.Len(t, r.URL.Path, len("/webhook/")+40) // +40 for txnID, a unique ID from payload's sha1 hash
|
|
switch testVersion {
|
|
case 1: // Version 1
|
|
assert.Equal(t, "push", r.Header.Get("X-GitHub-Event"))
|
|
assert.Empty(t, r.Header.Get("Content-Type"))
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, `{"data": 42}`, string(body))
|
|
|
|
case 2: // Version 2
|
|
assert.Equal(t, "push", r.Header.Get("X-GitHub-Event"))
|
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
assert.JSONEq(t, version2Body, string(body))
|
|
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
t.Fatalf("unexpected url path %s", r.URL.Path)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
done <- struct{}{}
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
|
|
hook := &webhook_model.Webhook{
|
|
RepoID: 3,
|
|
IsActive: true,
|
|
Type: webhook_module.MATRIX,
|
|
URL: s.URL + "/webhook",
|
|
HTTPMethod: "PUT",
|
|
ContentType: webhook_model.ContentTypeJSON,
|
|
Meta: `{"message_type":0}`, // text
|
|
}
|
|
assert.NoError(t, webhook_model.CreateWebhook(t.Context(), hook))
|
|
|
|
t.Run("Version 1", func(t *testing.T) {
|
|
testVersion = 1
|
|
hookTask := &webhook_model.HookTask{
|
|
HookID: hook.ID,
|
|
EventType: webhook_module.HookEventPush,
|
|
PayloadContent: `{"data": 42}`,
|
|
PayloadVersion: 1,
|
|
}
|
|
|
|
hookTask, err := webhook_model.CreateHookTask(t.Context(), hookTask)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, hookTask)
|
|
|
|
assert.NoError(t, Deliver(t.Context(), hookTask))
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("waited to long for request to happen")
|
|
}
|
|
|
|
assert.True(t, hookTask.IsSucceed)
|
|
})
|
|
|
|
t.Run("Version 2", func(t *testing.T) {
|
|
p := pushTestPayload()
|
|
data, err := p.JSONPayload()
|
|
assert.NoError(t, err)
|
|
|
|
testVersion = 2
|
|
hookTask := &webhook_model.HookTask{
|
|
HookID: hook.ID,
|
|
EventType: webhook_module.HookEventPush,
|
|
PayloadContent: string(data),
|
|
PayloadVersion: 2,
|
|
}
|
|
|
|
hookTask, err = webhook_model.CreateHookTask(t.Context(), hookTask)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, hookTask)
|
|
|
|
assert.NoError(t, Deliver(t.Context(), hookTask))
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("waited to long for request to happen")
|
|
}
|
|
|
|
assert.True(t, hookTask.IsSucceed)
|
|
})
|
|
}
|
|
|
|
func TestWebhookDeliverSpecificTypes(t *testing.T) {
|
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
|
|
type hookCase struct {
|
|
gotBody chan []byte
|
|
httpMethod string // default to POST
|
|
}
|
|
|
|
cases := map[string]*hookCase{
|
|
webhook_module.SLACK: {},
|
|
webhook_module.DISCORD: {},
|
|
webhook_module.DINGTALK: {},
|
|
webhook_module.TELEGRAM: {},
|
|
webhook_module.MSTEAMS: {},
|
|
webhook_module.FEISHU: {},
|
|
webhook_module.MATRIX: {httpMethod: "PUT"},
|
|
webhook_module.WECHATWORK: {},
|
|
webhook_module.PACKAGIST: {},
|
|
}
|
|
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
typ := strings.Split(r.URL.Path, "/")[1] // URL: "/{webhook_type}/other-path"
|
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"), r.URL.Path)
|
|
assert.Equal(t, util.IfZero(cases[typ].httpMethod, "POST"), r.Method, "webhook test request %q", r.URL.Path)
|
|
body, _ := io.ReadAll(r.Body) // read request and send it back to the test by testcase's chan
|
|
cases[typ].gotBody <- body
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
|
|
p := pushTestPayload()
|
|
data, err := p.JSONPayload()
|
|
assert.NoError(t, err)
|
|
|
|
for typ := range cases {
|
|
cases[typ].gotBody = make(chan []byte, 1)
|
|
t.Run(typ, func(t *testing.T) {
|
|
t.Parallel()
|
|
hook := &webhook_model.Webhook{
|
|
RepoID: 3,
|
|
IsActive: true,
|
|
Type: typ,
|
|
URL: s.URL + "/" + typ,
|
|
Meta: "{}",
|
|
}
|
|
assert.NoError(t, webhook_model.CreateWebhook(t.Context(), hook))
|
|
|
|
hookTask := &webhook_model.HookTask{
|
|
HookID: hook.ID,
|
|
EventType: webhook_module.HookEventPush,
|
|
PayloadContent: string(data),
|
|
PayloadVersion: 2,
|
|
}
|
|
|
|
hookTask, err := webhook_model.CreateHookTask(t.Context(), hookTask)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, hookTask)
|
|
|
|
assert.NoError(t, Deliver(t.Context(), hookTask))
|
|
|
|
select {
|
|
case gotBody := <-cases[typ].gotBody:
|
|
assert.NotEqual(t, string(data), string(gotBody), "request body must be different from the event payload")
|
|
assert.Equal(t, hookTask.RequestInfo.Body, string(gotBody), "delivered webhook payload doesn't match saved request")
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("waited to long for request to happen")
|
|
}
|
|
|
|
assert.True(t, hookTask.IsSucceed)
|
|
})
|
|
}
|
|
}
|