mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-04-23 18:26:58 -04:00
Some checks are pending
/ release (push) Waiting to run
testing-integration / test-unit (push) Waiting to run
testing-integration / test-sqlite (push) Waiting to run
testing-integration / test-mariadb (v10.6) (push) Waiting to run
testing-integration / test-mariadb (v11.8) (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
Replace the anti-CSRF token with a [cross origin protection by Go](https://go.dev/doc/go1.25#nethttppkgnethttp) that uses a stateless way of verifying if a request was cross origin or not. This allows is to remove al lot of code and replace it with a few lines of code and we no longer have to hand roll this protection. The new protection uses indicators by the browser itself that indicate if the request is cross-origin, thus we no longer have to take care of ensuring the generated CSRF token is passed back to the server any request by the the browser will have send this indicator. Resolves forgejo/forgejo#3538 Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9830 Reviewed-by: oliverpool <oliverpool@noreply.codeberg.org> Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org> Co-authored-by: Gusted <postmaster@gusted.xyz> Co-committed-by: Gusted <postmaster@gusted.xyz>
504 lines
16 KiB
Go
504 lines
16 KiB
Go
// Copyright 2024 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
app_context "forgejo.org/services/context"
|
|
"forgejo.org/services/webhook"
|
|
"forgejo.org/tests"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestNewWebHookLink(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
session := loginUser(t, "user2")
|
|
|
|
webhooksLen := len(webhook.List())
|
|
baseurl := "/user2/repo1/settings/hooks"
|
|
tests := []string{
|
|
// webhook list page
|
|
baseurl,
|
|
// new webhook page
|
|
baseurl + "/gitea/new",
|
|
// edit webhook page
|
|
baseurl + "/1",
|
|
}
|
|
|
|
for _, url := range tests {
|
|
resp := session.MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK)
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
assert.Equal(t,
|
|
webhooksLen,
|
|
htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
|
|
"not all webhooks are listed in the 'new' dropdown")
|
|
}
|
|
|
|
// ensure that the "failure" pages has the full dropdown as well
|
|
resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/gitea/new", map[string]string{}), http.StatusUnprocessableEntity)
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
assert.Equal(t,
|
|
webhooksLen,
|
|
htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
|
|
"not all webhooks are listed in the 'new' dropdown on failure")
|
|
|
|
resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/1", map[string]string{}), http.StatusUnprocessableEntity)
|
|
htmlDoc = NewHTMLParser(t, resp.Body)
|
|
assert.Equal(t,
|
|
webhooksLen,
|
|
htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
|
|
"not all webhooks are listed in the 'new' dropdown on failure")
|
|
|
|
adminSession := loginUser(t, "user1")
|
|
t.Run("org3", func(t *testing.T) {
|
|
baseurl := "/org/org3/settings/hooks"
|
|
resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK)
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
assert.Equal(t,
|
|
webhooksLen,
|
|
htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
|
|
"not all webhooks are listed in the 'new' dropdown")
|
|
})
|
|
t.Run("admin", func(t *testing.T) {
|
|
baseurl := "/admin/hooks"
|
|
resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK)
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
assert.Equal(t,
|
|
webhooksLen,
|
|
htmlDoc.Find(`a[href^="/admin/default-hooks/"][href$="/new"]`).Length(),
|
|
"not all webhooks are listed in the 'new' dropdown for default-hooks")
|
|
assert.Equal(t,
|
|
webhooksLen,
|
|
htmlDoc.Find(`a[href^="/admin/system-hooks/"][href$="/new"]`).Length(),
|
|
"not all webhooks are listed in the 'new' dropdown for system-hooks")
|
|
})
|
|
}
|
|
|
|
func TestWebhookForms(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
session := loginUser(t, "user1")
|
|
|
|
t.Run("forgejo/required", testWebhookForms("forgejo", session, map[string]string{
|
|
"payload_url": "https://forgejo.example.com",
|
|
"http_method": "POST",
|
|
"content_type": "1", // json
|
|
}, map[string]string{
|
|
"payload_url": "",
|
|
}, map[string]string{
|
|
"http_method": "",
|
|
}, map[string]string{
|
|
"content_type": "",
|
|
}, map[string]string{
|
|
"payload_url": "invalid_url",
|
|
}, map[string]string{
|
|
"http_method": "INVALID",
|
|
}))
|
|
t.Run("forgejo/optional", testWebhookForms("forgejo", session, map[string]string{
|
|
"payload_url": "https://forgejo.example.com",
|
|
"http_method": "POST",
|
|
"content_type": "1", // json
|
|
"secret": "s3cr3t",
|
|
|
|
"branch_filter": "forgejo/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("gitea/required", testWebhookForms("gitea", session, map[string]string{
|
|
"payload_url": "https://gitea.example.com",
|
|
"http_method": "POST",
|
|
"content_type": "1", // json
|
|
}, map[string]string{
|
|
"payload_url": "",
|
|
}, map[string]string{
|
|
"http_method": "",
|
|
}, map[string]string{
|
|
"content_type": "",
|
|
}, map[string]string{
|
|
"payload_url": "invalid_url",
|
|
}, map[string]string{
|
|
"http_method": "INVALID",
|
|
}))
|
|
t.Run("gitea/optional", testWebhookForms("gitea", session, map[string]string{
|
|
"payload_url": "https://gitea.example.com",
|
|
"http_method": "POST",
|
|
"content_type": "1", // json
|
|
"secret": "s3cr3t",
|
|
|
|
"branch_filter": "gitea/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("gogs/required", testWebhookForms("gogs", session, map[string]string{
|
|
"payload_url": "https://gogs.example.com",
|
|
"content_type": "1", // json
|
|
}))
|
|
t.Run("gogs/optional", testWebhookForms("gogs", session, map[string]string{
|
|
"payload_url": "https://gogs.example.com",
|
|
"content_type": "1", // json
|
|
"secret": "s3cr3t",
|
|
|
|
"branch_filter": "gogs/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("slack/required", testWebhookForms("slack", session, map[string]string{
|
|
"payload_url": "https://slack.example.com",
|
|
"channel": "general",
|
|
}, map[string]string{
|
|
"channel": "",
|
|
}, map[string]string{
|
|
"channel": "invalid channel name",
|
|
}))
|
|
t.Run("slack/optional", testWebhookForms("slack", session, map[string]string{
|
|
"payload_url": "https://slack.example.com",
|
|
"channel": "#general",
|
|
"username": "john",
|
|
"icon_url": "https://slack.example.com/icon.png",
|
|
"color": "#dd4b39",
|
|
|
|
"branch_filter": "slack/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("discord/required", testWebhookForms("discord", session, map[string]string{
|
|
"username": "john",
|
|
"payload_url": "https://discord.example.com",
|
|
}))
|
|
t.Run("discord/optional", testWebhookForms("discord", session, map[string]string{
|
|
"payload_url": "https://discord.example.com",
|
|
"username": "john",
|
|
"icon_url": "https://discord.example.com/icon.png",
|
|
|
|
"branch_filter": "discord/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("dingtalk/required", testWebhookForms("dingtalk", session, map[string]string{
|
|
"payload_url": "https://dingtalk.example.com",
|
|
}))
|
|
t.Run("dingtalk/optional", testWebhookForms("dingtalk", session, map[string]string{
|
|
"payload_url": "https://dingtalk.example.com",
|
|
|
|
"branch_filter": "discord/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("telegram/required", testWebhookForms("telegram", session, map[string]string{
|
|
"bot_token": "123456",
|
|
"chat_id": "789",
|
|
}))
|
|
t.Run("telegram/optional", testWebhookForms("telegram", session, map[string]string{
|
|
"bot_token": "123456",
|
|
"chat_id": "789",
|
|
"thread_id": "abc",
|
|
|
|
"branch_filter": "telegram/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("msteams/required", testWebhookForms("msteams", session, map[string]string{
|
|
"payload_url": "https://msteams.example.com",
|
|
}))
|
|
t.Run("msteams/optional", testWebhookForms("msteams", session, map[string]string{
|
|
"payload_url": "https://msteams.example.com",
|
|
|
|
"branch_filter": "msteams/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("feishu/required", testWebhookForms("feishu", session, map[string]string{
|
|
"payload_url": "https://feishu.example.com",
|
|
}))
|
|
t.Run("feishu/optional", testWebhookForms("feishu", session, map[string]string{
|
|
"payload_url": "https://feishu.example.com",
|
|
|
|
"branch_filter": "feishu/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("matrix/required", testWebhookForms("matrix", session, map[string]string{
|
|
"homeserver_url": "https://matrix.example.com",
|
|
"access_token": "123456",
|
|
"room_id": "123",
|
|
}, map[string]string{
|
|
"access_token": "",
|
|
}))
|
|
t.Run("matrix/optional", testWebhookForms("matrix", session, map[string]string{
|
|
"homeserver_url": "https://matrix.example.com",
|
|
"access_token": "123456",
|
|
"room_id": "123",
|
|
"message_type": "1", // m.text
|
|
|
|
"branch_filter": "matrix/*",
|
|
}))
|
|
|
|
t.Run("wechatwork/required", testWebhookForms("wechatwork", session, map[string]string{
|
|
"payload_url": "https://wechatwork.example.com",
|
|
}))
|
|
t.Run("wechatwork/optional", testWebhookForms("wechatwork", session, map[string]string{
|
|
"payload_url": "https://wechatwork.example.com",
|
|
|
|
"branch_filter": "wechatwork/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("packagist/required", testWebhookForms("packagist", session, map[string]string{
|
|
"username": "john",
|
|
"api_token": "secret",
|
|
"package_url": "https://packagist.org/packages/example/framework",
|
|
}))
|
|
t.Run("packagist/optional", testWebhookForms("packagist", session, map[string]string{
|
|
"username": "john",
|
|
"api_token": "secret",
|
|
"package_url": "https://packagist.org/packages/example/framework",
|
|
|
|
"branch_filter": "packagist/*",
|
|
"authorization_header": "Bearer 123456",
|
|
}))
|
|
|
|
t.Run("sourcehut_builds/required", testWebhookForms("sourcehut_builds", session, map[string]string{
|
|
"payload_url": "https://sourcehut_builds.example.com",
|
|
"manifest_path": ".build.yml",
|
|
"visibility": "PRIVATE",
|
|
"access_token": "123456",
|
|
}, map[string]string{
|
|
"access_token": "",
|
|
}, map[string]string{
|
|
"manifest_path": "",
|
|
}, map[string]string{
|
|
"manifest_path": "/absolute",
|
|
}, map[string]string{
|
|
"visibility": "",
|
|
}, map[string]string{
|
|
"visibility": "INVALID",
|
|
}))
|
|
t.Run("sourcehut_builds/optional", testWebhookForms("sourcehut_builds", session, map[string]string{
|
|
"payload_url": "https://sourcehut_builds.example.com",
|
|
"manifest_path": ".build.yml",
|
|
"visibility": "PRIVATE",
|
|
"secrets": "on",
|
|
"access_token": "123456",
|
|
|
|
"branch_filter": "srht/*",
|
|
}))
|
|
}
|
|
|
|
func assertInput(t testing.TB, form *goquery.Selection, name string) string {
|
|
t.Helper()
|
|
input := form.Find(`input[name="` + name + `"]`)
|
|
if input.Length() != 1 {
|
|
form.Find("input").Each(func(i int, s *goquery.Selection) {
|
|
t.Logf("found <input name=%q />", s.AttrOr("name", ""))
|
|
})
|
|
t.Errorf("field <input name=%q /> found %d times, expected once", name, input.Length())
|
|
}
|
|
switch input.AttrOr("type", "") {
|
|
case "checkbox":
|
|
if _, checked := input.Attr("checked"); checked {
|
|
return "on"
|
|
}
|
|
return ""
|
|
default:
|
|
return input.AttrOr("value", "")
|
|
}
|
|
}
|
|
|
|
func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) {
|
|
return func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
t.Run("repo1", func(t *testing.T) {
|
|
testWebhookFormsShared(t, "/user2/repo1/settings/hooks", name, session, validFields, invalidPatches...)
|
|
})
|
|
t.Run("org3", func(t *testing.T) {
|
|
testWebhookFormsShared(t, "/org/org3/settings/hooks", name, session, validFields, invalidPatches...)
|
|
})
|
|
t.Run("system", func(t *testing.T) {
|
|
testWebhookFormsShared(t, "/admin/system-hooks", name, session, validFields, invalidPatches...)
|
|
})
|
|
t.Run("default", func(t *testing.T) {
|
|
testWebhookFormsShared(t, "/admin/default-hooks", name, session, validFields, invalidPatches...)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testWebhookFormsShared(t *testing.T, endpoint, name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) {
|
|
// new webhook form
|
|
resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK)
|
|
htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`)
|
|
|
|
testWebhookFormsSharedChooseEvents(t, htmlForm)
|
|
|
|
// fill the form
|
|
payload := map[string]string{
|
|
"events": "send_everything",
|
|
}
|
|
for k, v := range validFields {
|
|
assertInput(t, htmlForm, k)
|
|
payload[k] = v
|
|
}
|
|
if t.Failed() {
|
|
t.FailNow() // prevent further execution if the form could not be filled properly
|
|
}
|
|
|
|
// create the webhook (this redirects back to the hook list)
|
|
resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusSeeOther)
|
|
assertHasFlashMessages(t, resp, "success")
|
|
listEndpoint := resp.Header().Get("Location")
|
|
updateEndpoint := endpoint + "/"
|
|
if endpoint == "/admin/system-hooks" || endpoint == "/admin/default-hooks" {
|
|
updateEndpoint = "/admin/hooks/"
|
|
}
|
|
|
|
// find last created hook in the hook list
|
|
// (a bit hacky, but the list should be sorted)
|
|
resp = session.MakeRequest(t, NewRequest(t, "GET", listEndpoint), http.StatusOK)
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
selector := `a[href^="` + updateEndpoint + `"]`
|
|
if endpoint == "/admin/system-hooks" {
|
|
// system-hooks and default-hooks are listed on the same page
|
|
// add a specifier to select the latest system-hooks
|
|
// (the default-hooks are at the end, so no further specifier needed)
|
|
selector = `.admin-setting-content > div:first-of-type ` + selector
|
|
}
|
|
editFormURL := htmlDoc.Find(selector).Last().AttrOr("href", "")
|
|
assert.NotEmpty(t, editFormURL)
|
|
|
|
// edit webhook form
|
|
resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK)
|
|
htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`)
|
|
editPostURL := htmlForm.AttrOr("action", "")
|
|
assert.NotEmpty(t, editPostURL)
|
|
|
|
// fill the form
|
|
payload = map[string]string{
|
|
"events": "push_only",
|
|
}
|
|
for k, v := range validFields {
|
|
assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v)
|
|
payload[k] = v
|
|
}
|
|
|
|
// update the webhook
|
|
resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", editPostURL, payload), http.StatusSeeOther)
|
|
assertHasFlashMessages(t, resp, "success")
|
|
|
|
// check the updated webhook
|
|
resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK)
|
|
htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`)
|
|
for k, v := range validFields {
|
|
assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v)
|
|
}
|
|
|
|
if len(invalidPatches) > 0 {
|
|
// check that invalid fields are rejected
|
|
resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK)
|
|
htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`)
|
|
|
|
for _, invalidPatch := range invalidPatches {
|
|
t.Run("invalid", func(t *testing.T) {
|
|
// fill the form
|
|
payload := map[string]string{
|
|
"events": "send_everything",
|
|
}
|
|
for k, v := range validFields {
|
|
payload[k] = v
|
|
}
|
|
for k, v := range invalidPatch {
|
|
if v == "" {
|
|
delete(payload, k)
|
|
} else {
|
|
payload[k] = v
|
|
}
|
|
}
|
|
|
|
resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusUnprocessableEntity)
|
|
// check that the invalid form is pre-filled
|
|
htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`)
|
|
for k, v := range payload {
|
|
if k == "events" || v == "" {
|
|
// the 'events' is a radio input, which is buggy below
|
|
continue
|
|
}
|
|
assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v)
|
|
}
|
|
if t.Failed() {
|
|
t.Log(invalidPatch)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertHasFlashMessages(t *testing.T, resp *httptest.ResponseRecorder, expectedKeys ...string) {
|
|
seenKeys := make(map[string][]string, len(expectedKeys))
|
|
|
|
for _, cookie := range resp.Result().Cookies() {
|
|
if cookie.Name != app_context.CookieNameFlash {
|
|
continue
|
|
}
|
|
flash, _ := url.ParseQuery(cookie.Value)
|
|
for key, value := range flash {
|
|
// the key is itself url-encoded
|
|
if flash, err := url.ParseQuery(key); err == nil {
|
|
for key, value := range flash {
|
|
seenKeys[key] = value
|
|
}
|
|
} else {
|
|
seenKeys[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, k := range expectedKeys {
|
|
if len(seenKeys[k]) == 0 {
|
|
t.Errorf("missing expected flash message %q", k)
|
|
}
|
|
delete(seenKeys, k)
|
|
}
|
|
|
|
for k, v := range seenKeys {
|
|
t.Errorf("unexpected flash message %q: %q", k, v)
|
|
}
|
|
}
|
|
|
|
func testWebhookFormsSharedChooseEvents(t *testing.T, htmlForm *goquery.Selection) {
|
|
webhookTypes := []string{
|
|
"create",
|
|
"delete",
|
|
"fork",
|
|
"push",
|
|
"repository",
|
|
"release",
|
|
"package",
|
|
"wiki",
|
|
"issues",
|
|
"issue_assign",
|
|
"issue_label",
|
|
"issue_milestone",
|
|
"issue_comment",
|
|
"pull_request",
|
|
"pull_request_assign",
|
|
"pull_request_label",
|
|
"pull_request_milestone",
|
|
"pull_request_comment",
|
|
"pull_request_review",
|
|
"pull_request_sync",
|
|
"pull_request_review_request",
|
|
"action_failure",
|
|
"action_recover",
|
|
"action_success",
|
|
}
|
|
|
|
// check all types of webhooks are present in the form
|
|
for _, webhookType := range webhookTypes {
|
|
assertInput(t, htmlForm, webhookType)
|
|
}
|
|
}
|