Merge remote-tracking branch 'origin/main' into various-sec-fixes

# Conflicts:
#	routers/api/v1/api.go
This commit is contained in:
Nicolas
2026-06-15 22:12:27 +02:00
100 changed files with 3343 additions and 704 deletions
@@ -13,6 +13,7 @@ import (
issues_model "gitea.dev/models/issues"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/json"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
@@ -61,6 +62,44 @@ func TestAPIGetTrackedTimes(t *testing.T) {
assert.Equal(t, int64(6), filterAPITimes[1].ID)
}
// TestAPIGetTrackedTimesNonExistentUserFilter ensures filtering by a user that
// does not exist returns a clean 404 instead of panicking (nil pointer dereference).
func TestAPIGetTrackedTimesNonExistentUserFilter(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
assert.NoError(t, issue2.LoadRepo(t.Context()))
session := loginUser(t, user2.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopeReadRepository)
for _, tc := range []struct {
name string
url string
}{
{"repository level", fmt.Sprintf("/api/v1/repos/%s/%s/times?user=nonexistentuser", user2.Name, issue2.Repo.Name)},
{"issue level", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/times?user=nonexistentuser", user2.Name, issue2.Repo.Name, issue2.Index)},
} {
t.Run(tc.name, func(t *testing.T) {
req := NewRequest(t, "GET", tc.url).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusNotFound)
assert.True(t, json.Valid(resp.Body.Bytes()), "response body must be a single JSON value, got: %s", resp.Body.Bytes())
var apiError api.APIError
DecodeJSON(t, resp, &apiError)
assert.Contains(t, apiError.Message, "user does not exist")
})
}
t.Run("existing user", func(t *testing.T) {
req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/times?user=%s", user2.Name, issue2.Repo.Name, user2.Name).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, api.TrackedTimeList{})
})
}
func TestAPIDeleteTrackedTime(t *testing.T) {
defer tests.PrepareTestEnv(t)()
+111
View File
@@ -6,6 +6,7 @@ package integration
import (
"net/http"
"net/url"
"strings"
"testing"
auth_model "gitea.dev/models/auth"
@@ -62,3 +63,113 @@ func TestAPICompareBranches(t *testing.T) {
})
})
}
func TestAPIDownloadCompareDiffOrPatch(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
session := loginUser(t, "user2")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
t.Run("BranchToBranchDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
body := resp.Body.String()
assert.Contains(t, body, "diff --git ")
})
t.Run("BranchToBranchPatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=patch").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
body := resp.Body.String()
assert.True(t, strings.HasPrefix(body, "From "), "patch output should start with a format-patch header, got: %q", body[:min(40, len(body))])
})
t.Run("CommitToCommitDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/808038d2f71b0ab02099...c8e31bc7688741a5287f?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("BranchToCommitDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// 8babce96... is the head of remove-files-b; pairing it with add-csv guarantees a non-empty diff.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...8babce967f21b9dfa6987f943b91093dac58a4f0?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("TwoDotSeparator", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv..remove-files-b?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("SlashedBranchName", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// user2/repo1's `feature/1` branch contains a slash; the route must match it
// without URL-encoding. master and feature/1 happen to share a SHA in the fixture,
// so we only assert the route resolves (200 OK) rather than checking diff content.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/compare/master...feature/1?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
})
t.Run("UnknownOutputReturnsJSON", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// Only "diff"/"patch" switch to raw output; any other value falls through to JSON.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv...remove-files-b?output=foo").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
apiResp := DecodeJSON(t, resp, &api.Compare{})
assert.Equal(t, 2, apiResp.TotalCommits)
})
t.Run("SingleRefImplicitBase", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// No `...`/`..` separator: parseCompareInfo defaults the base to the
// repo's PR target branch (master for repo20) and compares it against
// the given head.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo20/compare/add-csv?output=diff").AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("PrivateRepoAnonymous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// repo16 is private; an unauthenticated request must not leak its existence.
req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/compare/master...good-sign?output=diff")
MakeRequest(t, req, http.StatusNotFound)
})
t.Run("CrossRepoFork", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
user13 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13})
repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
user13Sess := loginUser(t, "user13")
user13Token := getTokenForLoggedInUser(t, user13Sess, auth_model.AccessTokenScopeWriteRepository)
_, err := createFileInBranch(user13, repo11, createFileInBranchOptions{OldBranch: "master", NewBranch: "cross-repo-diff"}, map[string]string{"hello.txt": "hi\n"})
require.NoError(t, err)
req := NewRequest(t, "GET", "/api/v1/repos/user12/repo10/compare/master...user13:cross-repo-diff?output=diff").AddTokenAuth(user13Token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
})
}
+60
View File
@@ -10,12 +10,14 @@ import (
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/organization"
"gitea.dev/models/perm"
"gitea.dev/models/repo"
"gitea.dev/models/unit"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
api "gitea.dev/modules/structs"
"gitea.dev/services/convert"
"gitea.dev/tests"
@@ -303,3 +305,61 @@ func TestAPIGetTeamRepo(t *testing.T) {
AddTokenAuth(token5)
MakeRequest(t, req, http.StatusNotFound)
}
func TestAPITeamVisibilityAccess(t *testing.T) {
defer tests.PrepareTestEnv(t)()
insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team {
t.Helper()
team := &organization.Team{
OrgID: orgID,
LowerName: name,
Name: name,
AccessMode: perm.AccessModeRead,
Visibility: visibility,
}
assert.NoError(t, db.Insert(t.Context(), team))
return team
}
limitedTeam := insertTestTeam(t, 3, "limited-team", structs.VisibleTypeLimited)
// Org member who can read a limited team must not mutate its repos without membership.
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
token := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteOrganization)
req := NewRequestf(t, "PUT", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusForbidden)
publicTeam := insertTestTeam(t, 23, "public-team", structs.VisibleTypePublic)
// Public team in a private org must not be readable by outsiders.
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token = getUserToken(t, outsider.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d", publicTeam.ID).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// Member lookup must require org membership even for public teams.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/members/%s", publicTeam.ID, outsider.Name).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// A limited team's repo list must not leak repos the viewer cannot access.
// repo3 is private; user28 is an org3 member (team12, no repo access) who can
// read the limited team but has no access to repo3.
assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: 3, TeamID: limitedTeam.ID, RepoID: 3}))
user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
token28 := getUserToken(t, user28.Name, auth_model.AccessTokenScopeReadOrganization)
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", limitedTeam.ID).AddTokenAuth(token28)
resp := MakeRequest(t, req, http.StatusOK)
var repos []*api.Repository
DecodeJSON(t, resp, &repos)
for _, r := range repos {
assert.NotEqual(t, int64(3), r.ID, "must not leak inaccessible private repo3")
}
// The single-repo lookup must not confirm an inaccessible repo's existence.
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).AddTokenAuth(token28)
MakeRequest(t, req, http.StatusNotFound)
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
// TestAPIGetCurrentToken tests getting metadata of the currently authenticated token
func TestAPIGetCurrentToken(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Success with all scopes", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-all", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
resp := MakeRequest(t, req, http.StatusOK)
currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{})
assert.Equal(t, accessToken.ID, currentToken.ID)
assert.Equal(t, accessToken.Name, currentToken.Name)
assert.Equal(t, user.ID, currentToken.User.ID)
assert.Equal(t, user.Name, currentToken.User.Login)
})
t.Run("Success with limited scopes", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-get-current-token-limited", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeReadRepository})
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
resp := MakeRequest(t, req, http.StatusOK)
currentToken := DecodeJSON(t, resp, &api.CurrentAccessToken{})
assert.Equal(t, accessToken.ID, currentToken.ID)
assert.Equal(t, accessToken.Name, currentToken.Name)
assert.Equal(t, user.ID, currentToken.User.ID)
assert.Equal(t, user.Name, currentToken.User.Login)
})
t.Run("Bad token", func(t *testing.T) {
req := NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth("this does not exist")
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "GET", "/api/v1/token")
MakeRequest(t, req, http.StatusUnauthorized)
})
}
// TestAPITokenSelfService tests delete operations on token
func TestAPITokenSelfService(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("Success then verify deleted", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-delete-current-token", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
// Delete the token via the endpoint
req := NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusNoContent)
// Verify the token is deleted
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID})
// Verify the token can no longer be used for GET
req = NewRequest(t, "GET", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusUnauthorized)
// Verify the token can no longer be used for DELETE
req = NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth(accessToken.Token)
MakeRequest(t, req, http.StatusUnauthorized)
})
t.Run("Bad token", func(t *testing.T) {
req := NewRequest(t, "DELETE", "/api/v1/token").
AddTokenAuth("this does not exist")
MakeRequest(t, req, http.StatusUnauthorized)
req = NewRequest(t, "DELETE", "/api/v1/token")
MakeRequest(t, req, http.StatusUnauthorized)
})
}
+47
View File
@@ -167,6 +167,53 @@ Hello from 2
assert.Equal(t, 0, htmlDoc.doc.Find(".pullrequest-form").Length())
}
func TestCompareDownloadDiffOrPatch(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
t.Run("BranchToBranchDiff", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.diff")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("BranchToBranchPatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b.patch")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
assert.True(t, strings.HasPrefix(resp.Body.String(), "From "), "patch output should start with a format-patch header")
})
t.Run("SingleRefImplicitBase", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv.diff")
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "diff --git ")
})
t.Run("InvalidBaseRef", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo20/compare/does-not-exist...remove-files-b.diff")
session.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("PrivateRepoAnonymous", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// repo16 is private; an unauthenticated request must not leak its existence.
req := NewRequest(t, "GET", "/user2/repo16/compare/master...good-sign.diff")
MakeRequest(t, req, http.StatusNotFound)
})
}
func TestCompareCodeExpand(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+6 -33
View File
@@ -5,13 +5,12 @@ package integration
import (
"io"
"slices"
"strings"
"testing"
"gitea.dev/modules/test"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"golang.org/x/net/html"
)
// HTMLDoc struct
@@ -53,36 +52,10 @@ func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string
func assertHTMLEq(t testing.TB, expected, actual string) {
t.Helper()
if expected == actual {
if expected == actual { // fast path
return
}
exp, err := html.Parse(strings.NewReader(expected))
if !assert.NoError(t, err) {
return
}
act, err := html.Parse(strings.NewReader(actual))
if !assert.NoError(t, err) {
return
}
var normalize func(n *html.Node)
normalize = func(n *html.Node) {
slices.SortFunc(n.Attr, func(a, b html.Attribute) int {
if cmp := strings.Compare(a.Namespace, b.Namespace); cmp != 0 {
return cmp
}
if cmp := strings.Compare(a.Key, b.Key); cmp != 0 {
return cmp
}
return strings.Compare(a.Val, b.Val)
})
for c := n.FirstChild; c != nil; c = c.NextSibling {
normalize(c)
}
}
normalize(exp)
normalize(act)
var expNormalized, actNormalized strings.Builder
assert.NoError(t, html.Render(&expNormalized, exp))
assert.NoError(t, html.Render(&actNormalized, act))
assert.Equal(t, expNormalized.String(), actNormalized.String())
exp := test.NormalizeHTMLAttributes(t, expected)
act := test.NormalizeHTMLAttributes(t, actual)
assert.Equal(t, exp, act)
}
+1 -1
View File
@@ -1272,7 +1272,7 @@ Commit description.
commitMessage: `loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message`,
},
},
expectedMessage: `* looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo...`,
expectedMessage: "* looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo...\n\n",
},
{
name: "Test Co-authored-by",