mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-08 22:13:26 +09:00
fix: enforce public-only token scope and harden push options / locale parsing (#38323)
- **Locale DoS:** the `Locale` middleware passed the raw
`Accept-Language` header to `ParseAcceptLanguage`, whose guard only
counts `-` while the scanner aliases `_` to `-` — a large `_`-separated
header on an unauthenticated request burned CPU. The header is now
length-bounded before parsing.
- **Public-only token scope:** `GET /teams/{id}/repos`,
`.../repos/{org}/{repo}`, `/teams/{id}/activities/feeds`, and
`/users/{username}/orgs/{org}/permissions` still returned private
repo/activity/permission data to a public-only token. They now filter
via `TokenCanAccessRepo` / `ApplyPublicOnly` and reject non-public org
permissions.
- **Push-option visibility:** `repo.private` / `repo.template` push
options were applied to any existing repo, letting an owner/admin
silently flip visibility bypassing audit, webhooks, and notifications.
They are now honored only on push-to-create.
This commit is contained in:
+23
-8
@@ -31,18 +31,28 @@ func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ e
|
|||||||
type SearchTeamRepoOptions struct {
|
type SearchTeamRepoOptions struct {
|
||||||
db.ListOptions
|
db.ListOptions
|
||||||
TeamID int64
|
TeamID int64
|
||||||
|
// PublicOnly restricts the result (and count) to non-private repositories.
|
||||||
|
PublicOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opts *SearchTeamRepoOptions) toCond() builder.Cond {
|
||||||
|
cond := builder.NewCond()
|
||||||
|
if opts.TeamID > 0 {
|
||||||
|
cond = cond.And(builder.In("id",
|
||||||
|
builder.Select("repo_id").
|
||||||
|
From("team_repo").
|
||||||
|
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if opts.PublicOnly {
|
||||||
|
cond = cond.And(builder.Eq{"is_private": false})
|
||||||
|
}
|
||||||
|
return cond
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTeamRepositories returns paginated repositories in team of organization.
|
// GetTeamRepositories returns paginated repositories in team of organization.
|
||||||
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
||||||
sess := db.GetEngine(ctx)
|
sess := db.GetEngine(ctx).Where(opts.toCond())
|
||||||
if opts.TeamID > 0 {
|
|
||||||
sess = sess.In("id",
|
|
||||||
builder.Select("repo_id").
|
|
||||||
From("team_repo").
|
|
||||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if opts.PageSize > 0 {
|
if opts.PageSize > 0 {
|
||||||
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||||||
}
|
}
|
||||||
@@ -51,6 +61,11 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (Repo
|
|||||||
Find(&repos)
|
Find(&repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountTeamRepositories returns the number of repositories in team of organization matching opts.
|
||||||
|
func CountTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (int64, error) {
|
||||||
|
return db.GetEngine(ctx).Where(opts.toCond()).Count(new(Repository))
|
||||||
|
}
|
||||||
|
|
||||||
// AccessibleReposEnvironment operations involving the repositories that are
|
// AccessibleReposEnvironment operations involving the repositories that are
|
||||||
// accessible to a particular user
|
// accessible to a particular user
|
||||||
type AccessibleReposEnvironment interface {
|
type AccessibleReposEnvironment interface {
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ import (
|
|||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxAcceptLanguageLen bounds the Accept-Language header before it reaches
|
||||||
|
// language.ParseAcceptLanguage. That parser has quadratic-time behavior on long
|
||||||
|
// malformed inputs, and its built-in guard only counts "-" separators while the
|
||||||
|
// scanner treats "_" as an alias for "-", so a "_"-heavy header slips past the
|
||||||
|
// guard and burns CPU. Only the leading (highest-priority) languages are used, so
|
||||||
|
// truncating a longer header is safe.
|
||||||
|
const maxAcceptLanguageLen = 200
|
||||||
|
|
||||||
|
// parseAcceptLanguage parses the Accept-Language header after bounding its length
|
||||||
|
// to avoid a quadratic-time DoS on attacker-controlled input.
|
||||||
|
func parseAcceptLanguage(header string) []language.Tag {
|
||||||
|
if len(header) > maxAcceptLanguageLen {
|
||||||
|
header = header[:maxAcceptLanguageLen]
|
||||||
|
}
|
||||||
|
tags, _, _ := language.ParseAcceptLanguage(header)
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
// Locale handle locale
|
// Locale handle locale
|
||||||
func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
||||||
// 1. Check URL arguments.
|
// 1. Check URL arguments.
|
||||||
@@ -35,7 +53,7 @@ func Locale(resp http.ResponseWriter, req *http.Request) translation.Locale {
|
|||||||
// 3. Get language information from 'Accept-Language'.
|
// 3. Get language information from 'Accept-Language'.
|
||||||
// The first element in the list is chosen to be the default language automatically.
|
// The first element in the list is chosen to be the default language automatically.
|
||||||
if len(lang) == 0 {
|
if len(lang) == 0 {
|
||||||
tags, _, _ := language.ParseAcceptLanguage(req.Header.Get("Accept-Language"))
|
tags := parseAcceptLanguage(req.Header.Get("Accept-Language"))
|
||||||
tag := translation.Match(tags...)
|
tag := translation.Match(tags...)
|
||||||
lang = tag.String()
|
lang = tag.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAcceptLanguage(t *testing.T) {
|
||||||
|
// a normal header is parsed and its leading language preserved
|
||||||
|
tags := parseAcceptLanguage("de-DE,de;q=0.9,en;q=0.8")
|
||||||
|
assert.NotEmpty(t, tags)
|
||||||
|
assert.Equal(t, "de-DE", tags[0].String())
|
||||||
|
|
||||||
|
// an oversized "_"-separated header would drive ParseAcceptLanguage into its
|
||||||
|
// quadratic-time path (the built-in guard only counts "-"); the length bound
|
||||||
|
// keeps the input passed to the parser small so it cannot be used for a DoS.
|
||||||
|
malicious := strings.Repeat("_aaaaaaaaa", 1<<16) // ~640 KiB, zero "-" characters
|
||||||
|
assert.Greater(t, len(malicious), maxAcceptLanguageLen)
|
||||||
|
tags = parseAcceptLanguage(malicious)
|
||||||
|
// no panic / hang, and nothing meaningful is parsed out of the garbage
|
||||||
|
assert.Empty(t, tags)
|
||||||
|
}
|
||||||
@@ -145,6 +145,13 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
|
|||||||
|
|
||||||
op := api.OrganizationPermissions{}
|
op := api.OrganizationPermissions{}
|
||||||
|
|
||||||
|
// A public-only token must not disclose membership/permission details of a
|
||||||
|
// non-public org, even for the token owner's own private orgs.
|
||||||
|
if ctx.PublicOnly && !o.Visibility.IsPublic() {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
|
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
|
||||||
ctx.APIErrorNotFound()
|
ctx.APIErrorNotFound()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -567,10 +567,20 @@ func GetTeamRepos(ctx *context.APIContext) {
|
|||||||
|
|
||||||
team := ctx.Org.Team
|
team := ctx.Org.Team
|
||||||
listOptions := utils.GetListOptions(ctx)
|
listOptions := utils.GetListOptions(ctx)
|
||||||
teamRepos, err := repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{
|
// A public-only token must not expose (or count) private repos, even when the
|
||||||
|
// doer owning the token otherwise has access to them, so filter them out at the
|
||||||
|
// query level to keep the returned page and the total-count header consistent.
|
||||||
|
searchOpts := &repo_model.SearchTeamRepoOptions{
|
||||||
ListOptions: listOptions,
|
ListOptions: listOptions,
|
||||||
TeamID: team.ID,
|
TeamID: team.ID,
|
||||||
})
|
PublicOnly: ctx.PublicOnly,
|
||||||
|
}
|
||||||
|
teamRepos, err := repo_model.GetTeamRepositories(ctx, searchOpts)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := repo_model.CountTeamRepositories(ctx, searchOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -584,14 +594,16 @@ func GetTeamRepos(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
// A team's repo list is reachable by non-team-members through the team's
|
// A team's repo list is reachable by non-team-members through the team's
|
||||||
// visibility tier, so never expose repos (incl. their names) the doer
|
// visibility tier, so never expose repos (incl. their names) the doer
|
||||||
// cannot access.
|
// cannot access. This per-repo visibility trim can't be expressed in the
|
||||||
|
// SQL count above without regressing per-unit public access, so for such
|
||||||
|
// non-members the total-count header may be a small upper bound.
|
||||||
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
repos = append(repos, convert.ToRepo(ctx, repo, permission))
|
repos = append(repos, convert.ToRepo(ctx, repo, permission))
|
||||||
}
|
}
|
||||||
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
|
ctx.SetLinkHeader(count, listOptions.PageSize)
|
||||||
ctx.SetTotalCountHeader(int64(team.NumRepos))
|
ctx.SetTotalCountHeader(count)
|
||||||
ctx.JSON(http.StatusOK, repos)
|
ctx.JSON(http.StatusOK, repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +642,12 @@ func GetTeamRepo(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A public-only token must not confirm the existence of a private repo.
|
||||||
|
if !ctx.TokenCanAccessRepo(repo) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !organization.HasTeamRepo(ctx, ctx.Org.Team.OrgID, ctx.Org.Team.ID, repo.ID) {
|
if !organization.HasTeamRepo(ctx, ctx.Org.Team.OrgID, ctx.Org.Team.ID, repo.ID) {
|
||||||
ctx.APIErrorNotFound()
|
ctx.APIErrorNotFound()
|
||||||
return
|
return
|
||||||
@@ -889,6 +907,8 @@ func ListTeamActivityFeeds(ctx *context.APIContext) {
|
|||||||
Date: ctx.FormString("date"),
|
Date: ctx.FormString("date"),
|
||||||
ListOptions: listOptions,
|
ListOptions: listOptions,
|
||||||
}
|
}
|
||||||
|
// A public-only token must not receive private activity entries.
|
||||||
|
opts.ApplyPublicOnly(ctx.PublicOnly)
|
||||||
|
|
||||||
feeds, count, err := feed_service.GetFeeds(ctx, opts)
|
feeds, count, err := feed_service.GetFeeds(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -160,10 +160,17 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: these options are not quite right, for example: changing visibility should do more works than just setting the is_private flag
|
// Only honor these options while the repo is still empty (the push-to-create
|
||||||
// These options should only be used for "push-to-create"
|
// case). On a populated repo a bare "git push -o repo.private=..." would
|
||||||
|
// silently flip visibility, bypassing the audit log, webhooks and notifications.
|
||||||
|
if !repo.IsEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The repo is empty and being initialized by this push, so there is no
|
||||||
|
// dependent state (webhooks, notifications, visibility fan-out) to reconcile
|
||||||
|
// yet; setting the flags directly is sufficient in this push-to-create case.
|
||||||
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
if isPrivate.Has() && repo.IsPrivate != isPrivate.Value() {
|
||||||
// TODO: it needs to do more work
|
|
||||||
repo.IsPrivate = isPrivate.Value()
|
repo.IsPrivate = isPrivate.Value()
|
||||||
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
if err = repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||||
log.Error("failed to update repo is_private: %v", err)
|
log.Error("failed to update repo is_private: %v", err)
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
|
"gitea.dev/models/organization"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
|
|
||||||
@@ -95,6 +100,59 @@ func TestAPIActivityFeedsPublicOnly(t *testing.T) {
|
|||||||
assertPublicActivitiesOnly(t, activities)
|
assertPublicActivitiesOnly(t, activities)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPIOrgPermissionsPublicOnly(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// user2 is a member of the private org private_org35
|
||||||
|
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "private_org35"})
|
||||||
|
|
||||||
|
// a full org-scoped token can read the membership permissions
|
||||||
|
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
req := NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// a public-only token must not disclose permissions for a private org
|
||||||
|
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopePublicOnly)
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/users/user2/orgs/%s/permissions", org.Name).AddTokenAuth(publicToken)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPITeamReposPublicOnly(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
// team 1 (Owners of org3) has access to the private repos org3/repo3 and org3/repo5
|
||||||
|
team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1})
|
||||||
|
privateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||||
|
privateRepo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
|
||||||
|
|
||||||
|
// a full org+repo scoped token sees the private repos
|
||||||
|
token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository)
|
||||||
|
req := NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(token)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
repos := DecodeJSON(t, resp, []api.Repository{})
|
||||||
|
assert.Contains(t, repoNames(repos), privateRepo.FullName())
|
||||||
|
|
||||||
|
// a public-only token must not receive any private repo
|
||||||
|
publicToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", team.ID).AddTokenAuth(publicToken)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
repos = DecodeJSON(t, resp, []api.Repository{})
|
||||||
|
for _, repo := range repos {
|
||||||
|
assert.False(t, repo.Private)
|
||||||
|
}
|
||||||
|
assert.NotContains(t, repoNames(repos), privateRepo.FullName())
|
||||||
|
assert.NotContains(t, repoNames(repos), privateRepo2.FullName())
|
||||||
|
// the total-count header must match the filtered page, otherwise it leaks the
|
||||||
|
// number of hidden private repos
|
||||||
|
assert.Equal(t, strconv.Itoa(len(repos)), resp.Header().Get("X-Total-Count"))
|
||||||
|
|
||||||
|
// the single-repo endpoint must not confirm a private repo for a public-only token
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusOK)
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s", team.ID, privateRepo.FullName()).AddTokenAuth(publicToken)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) {
|
func assertPublicActivitiesOnly(t *testing.T, activities []api.Activity) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
git_model "gitea.dev/models/git"
|
git_model "gitea.dev/models/git"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
@@ -140,6 +141,43 @@ func testGitPush(t *testing.T, u *url.URL) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGitPushVisibilityOption(t *testing.T) {
|
||||||
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
|
||||||
|
Name: "repo-visibility-option",
|
||||||
|
AutoInit: false,
|
||||||
|
DefaultBranch: "master",
|
||||||
|
IsPrivate: false,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, repo)
|
||||||
|
|
||||||
|
gitPath := t.TempDir()
|
||||||
|
doGitInitTestRepository(gitPath)(t)
|
||||||
|
|
||||||
|
oldPath, oldUser := u.Path, u.User
|
||||||
|
defer func() { u.Path, u.User = oldPath, oldUser }()
|
||||||
|
u.Path = repo.FullName() + ".git"
|
||||||
|
u.User = url.UserPassword(user.LowerName, userPassword)
|
||||||
|
doGitAddRemote(gitPath, "origin", u)(t)
|
||||||
|
|
||||||
|
// The first push into an empty repository is a "push-to-create", so the
|
||||||
|
// repo.private push option is honored to set the initial visibility.
|
||||||
|
doGitPushTestRepository(gitPath, "origin", "master", "-o", "repo.private=true")(t)
|
||||||
|
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||||
|
assert.True(t, repo.IsPrivate, "repo.private option should apply on push-to-create")
|
||||||
|
|
||||||
|
// The repository is now populated; a later push must NOT silently flip
|
||||||
|
// visibility, otherwise a repo admin could change it bypassing the audit
|
||||||
|
// trail, webhooks, and notifications a proper settings change would fire.
|
||||||
|
doGitCreateBranch(gitPath, "branch2")(t)
|
||||||
|
doGitPushTestRepository(gitPath, "origin", "branch2", "-o", "repo.private=false")(t)
|
||||||
|
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repo.ID})
|
||||||
|
assert.True(t, repo.IsPrivate, "repo.private option must be ignored on an existing repository")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func runTestGitPush(t *testing.T, u *url.URL, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
|
func runTestGitPush(t *testing.T, u *url.URL, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
|
repo, err := repo_service.CreateRepository(t.Context(), user, user, repo_service.CreateRepoOptions{
|
||||||
|
|||||||
Reference in New Issue
Block a user