feat: replace repo based server-side hooks with centralised hooks (#10397)

This PR is replacing repository based hooks hooks with centralised files, this way the files don't need to be copied into every repository, only one line of config need to be added in the repository.

Closes: #3523

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10397
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Gabor Pihaj 2026-04-27 22:34:46 +02:00 committed by Gusted
parent f05ff7ec5b
commit 73b30acbd0
26 changed files with 418 additions and 439 deletions

View file

@ -562,12 +562,12 @@ test: test-frontend test-backend
.PHONY: test-backend
test-backend: | compute-go-test-packages
@echo "Running go test with $(GOTESTFLAGS) -tags '$(TEST_TAGS)'..."
@TZ=UTC $(GOTEST) $(GOTESTFLAGS) -tags='$(TEST_TAGS)' $(GO_TEST_PACKAGES)
@TZ=UTC GITEA_ROOT="$(CURDIR)" $(GOTEST) $(GOTESTFLAGS) -tags='$(TEST_TAGS)' $(GO_TEST_PACKAGES)
.PHONY: test-remote-cacher
test-remote-cacher:
@echo "Running go test with $(GOTESTFLAGS) -tags '$(TEST_TAGS)'..."
@$(GOTEST) $(GOTESTFLAGS) -tags='$(TEST_TAGS)' $(GO_TEST_REMOTE_CACHER_PACKAGES)
GITEA_ROOT="$(CURDIR)" $(GOTEST) $(GOTESTFLAGS) -tags='$(TEST_TAGS)' $(GO_TEST_REMOTE_CACHER_PACKAGES)
.PHONY: test-frontend
test-frontend: node_modules
@ -592,7 +592,7 @@ test-check:
.PHONY: test\#%
test\#%: | compute-go-test-packages
@echo "Running go test with $(GOTESTFLAGS) -tags '$(TEST_TAGS)'..."
@TZ=UTC $(GOTEST) $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -run $(subst .,/,$*) $(GO_TEST_PACKAGES)
@TZ=UTC GITEA_ROOT="$(CURDIR)" $(GOTEST) $(GOTESTFLAGS) -tags='$(TEST_TAGS)' -run $(subst .,/,$*) $(GO_TEST_PACKAGES)
coverage-merge:
rm -fr coverage/merged ; mkdir -p coverage/merged

View file

@ -47,7 +47,6 @@ func subcmdRegenerate() *cli.Command {
Name: "regenerate",
Usage: "Regenerate specific files",
Commands: []*cli.Command{
microcmdRegenHooks,
microcmdRegenKeys,
},
}

View file

@ -7,36 +7,15 @@ import (
"context"
asymkey_model "forgejo.org/models/asymkey"
"forgejo.org/modules/graceful"
repo_service "forgejo.org/services/repository"
"github.com/urfave/cli/v3"
)
var (
microcmdRegenHooks = &cli.Command{
Name: "hooks",
Usage: "Regenerate git-hooks",
Before: noDanglingArgs,
Action: runRegenerateHooks,
}
microcmdRegenKeys = &cli.Command{
Name: "keys",
Usage: "Regenerate authorized_keys file",
Before: noDanglingArgs,
Action: runRegenerateKeys,
}
)
func runRegenerateHooks(ctx context.Context, c *cli.Command) error {
ctx, cancel := installSignals(ctx)
defer cancel()
if err := initDB(ctx); err != nil {
return err
}
return repo_service.SyncRepositoryHooks(graceful.GetManager().ShutdownContext())
var microcmdRegenKeys = &cli.Command{
Name: "keys",
Usage: "Regenerate authorized_keys file",
Before: noDanglingArgs,
Action: runRegenerateKeys,
}
func runRegenerateKeys(ctx context.Context, c *cli.Command) error {

View file

@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
@ -62,7 +63,12 @@ func runTestApp(app *cli.Command, args ...string) (runResult, error) {
}
func TestCliCmd(t *testing.T) {
defaultWorkPath := filepath.Dir(setting.AppPath)
path, err := os.Executable()
if err != nil {
panic(err)
}
defaultWorkPath := filepath.Dir(path)
defaultCustomPath := filepath.Join(defaultWorkPath, "custom")
defaultCustomConf := filepath.Join(defaultCustomPath, "conf/app.ini")

View file

@ -47,10 +47,29 @@ func fatalTestError(fmtStr string, args ...any) {
// InitSettings initializes config provider and load common settings for tests
func InitSettings() {
if setting.CustomConf == "" {
setting.CustomConf = filepath.Join(setting.CustomPath, "conf/app-unittest-tmp.ini")
_ = os.Remove(setting.CustomConf)
InitCustomSettings("unittest.ini")
}
func InitCustomSettings(confFileName string) {
root := base.SetupGiteaRoot()
if root == "" {
fatalTestError("Environment variable $GITEA_ROOT not set")
}
setting.AppPath = filepath.Join(root, "gitea")
if setting.CustomConf == "" {
templateFile := confFileName + ".tmpl"
content, err := os.ReadFile(filepath.Join(root, "tests", templateFile))
if err != nil {
log.Fatalf("couldn't read config template: %s", templateFile)
}
err = os.WriteFile(filepath.Join(root, "tests", confFileName), content, 0o644)
if err != nil {
log.Fatalf("couldn't write config: %s", confFileName)
}
setting.CustomConf = filepath.Join(root, "tests", confFileName)
}
os.Setenv("GITEA_CONF", setting.CustomConf)
setting.InitCfgProvider(setting.CustomConf)
setting.LoadCommonSettings()
@ -72,9 +91,10 @@ func InitSettings() {
// TestOptions represents test options
type TestOptions struct {
FixtureFiles []string
SetUp func() error // SetUp will be executed before all tests in this package
TearDown func() error // TearDown will be executed after all tests in this package
FixtureFiles []string
SetUp func() error // SetUp will be executed before all tests in this package
TearDown func() error // TearDown will be executed after all tests in this package
IniFileOverride string
}
// MainTest a reusable TestMain(..) function for unit tests that need to use a
@ -97,7 +117,11 @@ func MainTest(m *testing.M, testOpts ...*TestOptions) {
giteaRoot = searchDir
setting.CustomPath = filepath.Join(giteaRoot, "custom")
InitSettings()
if len(testOpts) == 0 || testOpts[0].IniFileOverride == "" {
InitSettings()
} else {
InitCustomSettings(testOpts[0].IniFileOverride)
}
fixturesDir = filepath.Join(giteaRoot, "models", "fixtures")
var opts FixturesOptions

View file

@ -10,6 +10,7 @@ import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
@ -200,6 +201,11 @@ func InitFull(ctx context.Context) (err error) {
_, err = exec.LookPath("ssh")
HasSSHExecutable = err == nil
err = InitDelegateHooks(HomeDir())
if err != nil {
return nil
}
return syncGitConfig()
}
@ -229,6 +235,10 @@ func syncGitConfig() (err error) {
}
}
if err := configSet("core.hooksPath", path.Join(HomeDir(), "hooks")); err != nil {
return err
}
// Set git some configurations - these must be set to these values for forgejo to work correctly
if err := configSet("core.quotePath", "false"); err != nil {
return err

View file

@ -1,7 +1,7 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repository
package git
import (
"fmt"
@ -21,14 +21,25 @@ func getHookTemplates() (hookNames, hookTpls, giteaHookTpls []string) {
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
for hook in $(dirname $0)/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
# Custom hooks
custom_hooks_dir="./hooks/${hookname}.d"
if [ -d "${custom_hooks_dir}" ]; then
for hook in ${custom_hooks_dir}/*; do
if [ $(basename "${hook}") != "gitea" ]; then
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
fi
done
fi
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
@ -39,14 +50,25 @@ done
# AUTO GENERATED BY GITEA, DO NOT MODIFY
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
for hook in $(dirname $0)/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
"${hook}" $1 $2 $3
exitcodes="${exitcodes} $?"
done
# Custom hooks
custom_hooks_dir="./hooks/${hookname}.d"
if [ -d "${custom_hooks_dir}" ]; then
for hook in ${custom_hooks_dir}/*; do
if [ $(basename "${hook}") != "gitea" ]; then
test -x "${hook}" && test -f "${hook}" || continue
"${hook}" $1 $2 $3
exitcodes="${exitcodes} $?"
fi
done
fi
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
@ -58,14 +80,24 @@ done
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
for hook in $(dirname $0)/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
# Custom hooks
custom_hooks_dir="./hooks/${hookname}.d"
if [ -d "${custom_hooks_dir}" ]; then
for hook in ${custom_hooks_dir}/*; do
if [ $(basename "${hook}") != "gitea" ]; then
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
fi
done
fi
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
@ -104,10 +136,9 @@ done
return hookNames, hookTpls, giteaHookTpls
}
// CreateDelegateHooks creates all the hooks scripts for the repo
func CreateDelegateHooks(repoPath string) (err error) {
func InitDelegateHooks(path string) (err error) {
hookNames, hookTpls, giteaHookTpls := getHookTemplates()
hookDir := filepath.Join(repoPath, "hooks")
hookDir := filepath.Join(path, "hooks")
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
@ -144,14 +175,6 @@ func CreateDelegateHooks(repoPath string) (err error) {
return nil
}
func checkExecutable(filename string) bool {
fileInfo, err := os.Stat(filename)
if err != nil {
return false
}
return (fileInfo.Mode() & 0o100) > 0
}
func ensureExecutable(filename string) error {
fileInfo, err := os.Stat(filename)
if err != nil {
@ -163,66 +186,3 @@ func ensureExecutable(filename string) error {
mode := fileInfo.Mode() | 0o100
return os.Chmod(filename, mode)
}
// CheckDelegateHooks checks the hooks scripts for the repo
func CheckDelegateHooks(repoPath string) ([]string, error) {
hookNames, hookTpls, giteaHookTpls := getHookTemplates()
hookDir := filepath.Join(repoPath, "hooks")
results := make([]string, 0, 10)
for i, hookName := range hookNames {
oldHookPath := filepath.Join(hookDir, hookName)
newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
cont := false
isExist, err := util.IsExist(oldHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("old hook file %s does not exist", oldHookPath))
cont = true
}
isExist, err = util.IsExist(oldHookPath + ".d")
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", oldHookPath+".d", err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("hooks directory %s does not exist", oldHookPath+".d"))
cont = true
}
isExist, err = util.IsExist(newHookPath)
if err != nil {
results = append(results, fmt.Sprintf("unable to check if %s exists. Error: %v", newHookPath, err))
}
if err == nil && !isExist {
results = append(results, fmt.Sprintf("new hook file %s does not exist", newHookPath))
cont = true
}
if cont {
continue
}
contents, err := os.ReadFile(oldHookPath)
if err != nil {
return results, err
}
if string(contents) != hookTpls[i] {
results = append(results, fmt.Sprintf("old hook file %s is out of date", oldHookPath))
}
if !checkExecutable(oldHookPath) {
results = append(results, fmt.Sprintf("old hook file %s is not executable", oldHookPath))
}
contents, err = os.ReadFile(newHookPath)
if err != nil {
return results, err
}
if string(contents) != giteaHookTpls[i] {
results = append(results, fmt.Sprintf("new hook file %s is out of date", newHookPath))
}
if !checkExecutable(newHookPath) {
results = append(results, fmt.Sprintf("new hook file %s is not executable", newHookPath))
}
}
return results, nil
}

View file

@ -138,8 +138,6 @@ func CheckInitRepository(ctx context.Context, owner, name, objectFormatName stri
// Init git bare new repository.
if err = git.InitRepository(ctx, repoPath, true, objectFormatName); err != nil {
return fmt.Errorf("git.InitRepository: %w", err)
} else if err = CreateDelegateHooks(repoPath); err != nil {
return fmt.Errorf("createDelegateHooks: %w", err)
}
return nil
}

1
release-notes/10397.md Normal file
View file

@ -0,0 +1 @@
feat: replace repository based server-side hooks with centralised hooks. Migration guide is available in [Admin docs](https://forgejo.org/docs/latest/admin/upgrade/#when-upgrading-from--known-problematic-versions-or-upgrade-paths).

View file

@ -92,8 +92,6 @@ func syncAppConfForGit(ctx context.Context) error {
}
if updated {
log.Info("re-sync repository hooks ...")
mustInitCtx(ctx, repo_service.SyncRepositoryHooks)
return system.AppState.Set(ctx, runtimeState)
}
return nil

View file

@ -34,5 +34,5 @@ func TestRoutes(t *testing.T) {
}
func TestMain(m *testing.M) {
unittest.MainTest(m)
unittest.MainTest(m, &unittest.TestOptions{IniFileOverride: "install.ini"})
}

View file

@ -86,16 +86,6 @@ func registerRewriteAllPrincipalKeys() {
})
}
func registerRepositoryUpdateHook() {
RegisterTaskFatal("resync_all_hooks", &BaseConfig{
Enabled: false,
RunAtStart: false,
Schedule: "@every 72h",
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
return repo_service.SyncRepositoryHooks(ctx)
})
}
func registerReinitMissingRepositories() {
RegisterTaskFatal("reinit_missing_repos", &BaseConfig{
Enabled: false,
@ -251,7 +241,6 @@ func initExtendedTasks() {
registerGarbageCollectRepositories()
registerRewriteAllPublicKeys()
registerRewriteAllPrincipalKeys()
registerRepositoryUpdateHook()
registerReinitMissingRepositories()
registerDeleteMissingRepositories()
registerRemoveRandomAvatars()

View file

@ -18,7 +18,6 @@ import (
"forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/log"
"forgejo.org/modules/repository"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"forgejo.org/modules/util"
@ -48,31 +47,6 @@ func checkScriptType(ctx context.Context, logger log.Logger, autofix bool) error
return nil
}
func checkHooks(ctx context.Context, logger log.Logger, autofix bool) error {
if err := iterateRepositories(ctx, func(repo *repo_model.Repository) error {
results, err := repository.CheckDelegateHooks(repo.RepoPath())
if err != nil {
logger.Critical("Unable to check delegate hooks for repo %-v. ERROR: %v", repo, err)
return fmt.Errorf("Unable to check delegate hooks for repo %-v. ERROR: %w", repo, err)
}
if len(results) > 0 && autofix {
logger.Warn("Regenerated hooks for %s", repo.FullName())
if err := repository.CreateDelegateHooks(repo.RepoPath()); err != nil {
logger.Critical("Unable to recreate delegate hooks for %-v. ERROR: %v", repo, err)
return fmt.Errorf("Unable to recreate delegate hooks for %-v. ERROR: %w", repo, err)
}
}
for _, result := range results {
logger.Warn(result)
}
return nil
}); err != nil {
logger.Critical("Errors noted whilst checking delegate hooks.")
return err
}
return nil
}
func checkUserStarNum(ctx context.Context, logger log.Logger, autofix bool) error {
if autofix {
if err := models.DoctorUserStarNum(ctx); err != nil {
@ -261,13 +235,6 @@ func init() {
Run: checkScriptType,
Priority: 5,
})
Register(&Check{
Title: "Check if hook files are up-to-date and executable",
Name: "hooks",
IsDefault: false,
Run: checkHooks,
Priority: 6,
})
Register(&Check{
Title: "Recalculate Stars number for all user",
Name: "recalculate-stars-number",

View file

@ -117,10 +117,6 @@ func adoptRepository(ctx context.Context, repoPath string, repo *repo_model.Repo
return fmt.Errorf("adoptRepository: path does not already exist: %s", repoPath)
}
if err := repo_module.CreateDelegateHooks(repoPath); err != nil {
return fmt.Errorf("createDelegateHooks: %w", err)
}
repo.IsEmpty = false
if len(defaultBranch) > 0 {

View file

@ -164,10 +164,6 @@ func ForkRepositoryIfNotExists(ctx context.Context, doer, owner *user_model.User
return fmt.Errorf("git update-server-info: %w", err)
}
if err = repo_module.CreateDelegateHooks(repoPath); err != nil {
return fmt.Errorf("createDelegateHooks: %w", err)
}
gitRepo, err := gitrepo.OpenRepository(txCtx, repo)
if err != nil {
return fmt.Errorf("OpenRepository: %w", err)

View file

@ -5,51 +5,13 @@ package repository
import (
"context"
"fmt"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/webhook"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/log"
repo_module "forgejo.org/modules/repository"
"xorm.io/builder"
)
// SyncRepositoryHooks rewrites all repositories' pre-receive, update and post-receive hooks
// to make sure the binary and custom conf path are up-to-date.
func SyncRepositoryHooks(ctx context.Context) error {
log.Trace("Doing: SyncRepositoryHooks")
if err := db.Iterate(
ctx,
builder.Gt{"id": 0},
func(ctx context.Context, repo *repo_model.Repository) error {
select {
case <-ctx.Done():
return db.ErrCancelledf("before sync repository hooks for %s", repo.FullName())
default:
}
if err := repo_module.CreateDelegateHooks(repo.RepoPath()); err != nil {
return fmt.Errorf("SyncRepositoryHook: %w", err)
}
if repo.HasWiki() {
if err := repo_module.CreateDelegateHooks(repo.WikiPath()); err != nil {
return fmt.Errorf("SyncRepositoryHook: %w", err)
}
}
return nil
},
); err != nil {
return err
}
log.Trace("Finished: SyncRepositoryHooks")
return nil
}
// GenerateGitHooks generates git hooks from a template repository
func GenerateGitHooks(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
generateGitRepo, err := gitrepo.OpenRepository(ctx, generateRepo)

View file

@ -258,14 +258,6 @@ func cleanUpMigrateGitConfig(ctx context.Context, repoPath string) error {
// CleanUpMigrateInfo finishes migrating repository and/or wiki with things that don't need to be done for mirrors.
func CleanUpMigrateInfo(ctx context.Context, repo *repo_model.Repository) (*repo_model.Repository, error) {
repoPath := repo.RepoPath()
if err := repo_module.CreateDelegateHooks(repoPath); err != nil {
return repo, fmt.Errorf("createDelegateHooks: %w", err)
}
if repo.HasWiki() {
if err := repo_module.CreateDelegateHooks(repo.WikiPath()); err != nil {
return repo, fmt.Errorf("createDelegateHooks.(wiki): %w", err)
}
}
_, _, err := git.NewCommand(ctx, "remote", "rm", "origin").RunStdString(&git.RunOpts{Dir: repoPath})
if err != nil && !git.IsRemoteNotExistError(err) {

View file

@ -42,8 +42,6 @@ func InitWiki(ctx context.Context, repo *repo_model.Repository) error {
if err := git.InitRepository(ctx, repo.WikiPath(), true, repo.ObjectFormatName); err != nil {
return fmt.Errorf("InitRepository: %w", err)
} else if err = repo_module.CreateDelegateHooks(repo.WikiPath()); err != nil {
return fmt.Errorf("createDelegateHooks: %w", err)
} else if _, _, err = git.NewCommand(ctx, "symbolic-ref", "HEAD").AddDynamicArguments(git.BranchPrefix + branch).RunStdString(&git.RunOpts{Dir: repo.WikiPath()}); err != nil {
return fmt.Errorf("unable to set default wiki branch to %s: %w", branch, err)
}

0
tests/install.ini.tmpl Normal file
View file

View file

@ -426,11 +426,11 @@ func TestAPICron(t *testing.T) {
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "31", resp.Header().Get("X-Total-Count"))
assert.Equal(t, "30", resp.Header().Get("X-Total-Count"))
var crons []api.Cron
DecodeJSON(t, resp, &crons)
assert.Len(t, crons, 31)
assert.Len(t, crons, 30)
})
t.Run("Execute", func(t *testing.T) {

View file

@ -7,6 +7,7 @@ package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
repo_model "forgejo.org/models/repo"
@ -44,169 +45,169 @@ func getIssueConfig(t *testing.T, owner, repo string) api.IssueConfig {
}
func TestAPIRepoGetIssueConfig(t *testing.T) {
defer tests.PrepareTestEnv(t)()
onApplicationRun(t, func(t *testing.T, _ *url.URL) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
t.Run("Default", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("Default", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
assert.True(t, issueConfig.BlankIssuesEnabled)
assert.Empty(t, issueConfig.ContactLinks)
})
assert.True(t, issueConfig.BlankIssuesEnabled)
assert.Empty(t, issueConfig.ContactLinks)
})
t.Run("DisableBlankIssues", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("DisableBlankIssues", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
config := make(map[string]any)
config["blank_issues_enabled"] = false
config := make(map[string]any)
config["blank_issues_enabled"] = false
createIssueConfig(t, owner, repo, config)
createIssueConfig(t, owner, repo, config)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
assert.False(t, issueConfig.BlankIssuesEnabled)
assert.Empty(t, issueConfig.ContactLinks)
})
assert.False(t, issueConfig.BlankIssuesEnabled)
assert.Empty(t, issueConfig.ContactLinks)
})
t.Run("ContactLinks", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("ContactLinks", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
contactLink := make(map[string]string)
contactLink["name"] = "TestName"
contactLink["url"] = "https://example.com"
contactLink["about"] = "TestAbout"
contactLink := make(map[string]string)
contactLink["name"] = "TestName"
contactLink["url"] = "https://example.com"
contactLink["about"] = "TestAbout"
config := make(map[string]any)
config["contact_links"] = []map[string]string{contactLink}
config := make(map[string]any)
config["contact_links"] = []map[string]string{contactLink}
createIssueConfig(t, owner, repo, config)
createIssueConfig(t, owner, repo, config)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
assert.True(t, issueConfig.BlankIssuesEnabled)
assert.Len(t, issueConfig.ContactLinks, 1)
assert.True(t, issueConfig.BlankIssuesEnabled)
assert.Len(t, issueConfig.ContactLinks, 1)
assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name)
assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL)
assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About)
})
assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name)
assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL)
assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About)
})
t.Run("Full", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("Full", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
contactLink := make(map[string]string)
contactLink["name"] = "TestName"
contactLink["url"] = "https://example.com"
contactLink["about"] = "TestAbout"
contactLink := make(map[string]string)
contactLink["name"] = "TestName"
contactLink["url"] = "https://example.com"
contactLink["about"] = "TestAbout"
config := make(map[string]any)
config["blank_issues_enabled"] = false
config["contact_links"] = []map[string]string{contactLink}
config := make(map[string]any)
config["blank_issues_enabled"] = false
config["contact_links"] = []map[string]string{contactLink}
createIssueConfig(t, owner, repo, config)
createIssueConfig(t, owner, repo, config)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
assert.False(t, issueConfig.BlankIssuesEnabled)
assert.Len(t, issueConfig.ContactLinks, 1)
assert.False(t, issueConfig.BlankIssuesEnabled)
assert.Len(t, issueConfig.ContactLinks, 1)
assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name)
assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL)
assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About)
assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name)
assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL)
assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About)
})
})
}
func TestAPIRepoIssueConfigPaths(t *testing.T) {
defer tests.PrepareTestEnv(t)()
onApplicationRun(t, func(t *testing.T, _ *url.URL) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
templateConfigCandidates := []string{
".forgejo/ISSUE_TEMPLATE/config",
".forgejo/issue_template/config",
".gitea/ISSUE_TEMPLATE/config",
".gitea/issue_template/config",
".github/ISSUE_TEMPLATE/config",
".github/issue_template/config",
"docs/issue_template/config",
}
for _, candidate := range templateConfigCandidates {
for _, extension := range []string{".yaml", ".yml"} {
fullPath := candidate + extension
t.Run(fullPath, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
configMap := make(map[string]any)
configMap["blank_issues_enabled"] = false
configData, err := yaml.Marshal(configMap)
require.NoError(t, err)
_, err = createFileInBranch(owner, repo, fullPath, repo.DefaultBranch, string(configData))
require.NoError(t, err)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
assert.False(t, issueConfig.BlankIssuesEnabled)
assert.Empty(t, issueConfig.ContactLinks)
err = deleteFileInBranch(owner, repo, fullPath, repo.DefaultBranch)
require.NoError(t, err)
})
templateConfigCandidates := []string{
".forgejo/ISSUE_TEMPLATE/config",
".forgejo/issue_template/config",
".gitea/ISSUE_TEMPLATE/config",
".gitea/issue_template/config",
".github/ISSUE_TEMPLATE/config",
".github/issue_template/config",
"docs/issue_template/config",
}
}
for _, candidate := range templateConfigCandidates {
for _, extension := range []string{".yaml", ".yml"} {
fullPath := candidate + extension
t.Run(fullPath, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
configMap := make(map[string]any)
configMap["blank_issues_enabled"] = false
configData, err := yaml.Marshal(configMap)
require.NoError(t, err)
_, err = createFileInBranch(owner, repo, fullPath, repo.DefaultBranch, string(configData))
require.NoError(t, err)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)
assert.False(t, issueConfig.BlankIssuesEnabled)
assert.Empty(t, issueConfig.ContactLinks)
err = deleteFileInBranch(owner, repo, fullPath, repo.DefaultBranch)
require.NoError(t, err)
})
}
}
})
}
func TestAPIRepoValidateIssueConfig(t *testing.T) {
defer tests.PrepareTestEnv(t)()
onApplicationRun(t, func(t *testing.T, _ *url.URL) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config/validate", owner.Name, repo.Name)
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config/validate", owner.Name, repo.Name)
t.Run("Valid", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("Valid", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
var issueConfigValidation api.IssueConfigValidation
DecodeJSON(t, resp, &issueConfigValidation)
var issueConfigValidation api.IssueConfigValidation
DecodeJSON(t, resp, &issueConfigValidation)
assert.True(t, issueConfigValidation.Valid)
assert.Empty(t, issueConfigValidation.Message)
})
assert.True(t, issueConfigValidation.Valid)
assert.Empty(t, issueConfigValidation.Message)
})
t.Run("Invalid", func(t *testing.T) {
dirs := []string{".gitea", ".forgejo", "docs"}
for _, dir := range dirs {
t.Run(dir, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer func() {
deleteFileInBranch(owner, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch)
}()
t.Run("Invalid", func(t *testing.T) {
dirs := []string{".gitea", ".forgejo", "docs"}
for _, dir := range dirs {
t.Run(dir, func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer func() {
deleteFileInBranch(owner, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch)
}()
config := make(map[string]any)
config["blank_issues_enabled"] = "Test"
config := make(map[string]any)
config["blank_issues_enabled"] = "Test"
createIssueConfigInDirectory(t, owner, repo, dir, config)
createIssueConfigInDirectory(t, owner, repo, dir, config)
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
req := NewRequest(t, "GET", urlStr)
resp := MakeRequest(t, req, http.StatusOK)
var issueConfigValidation api.IssueConfigValidation
DecodeJSON(t, resp, &issueConfigValidation)
var issueConfigValidation api.IssueConfigValidation
DecodeJSON(t, resp, &issueConfigValidation)
assert.False(t, issueConfigValidation.Valid)
assert.NotEmpty(t, issueConfigValidation.Message)
})
}
assert.False(t, issueConfigValidation.Valid)
assert.NotEmpty(t, issueConfigValidation.Message)
})
}
})
})
}

View file

@ -10,6 +10,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"testing"
auth_model "forgejo.org/models/auth"
@ -44,96 +45,96 @@ func TestEmptyRepo(t *testing.T) {
}
func TestEmptyRepoAddFile(t *testing.T) {
defer tests.PrepareTestEnv(t)()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user30")
req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`)
assert.Empty(t, doc.AttrOr("checked", "_no_"))
req = NewRequestWithValues(t, "POST", "/user30/empty/_new/"+setting.Repository.DefaultBranch, map[string]string{
"commit_choice": "direct",
"tree_path": "test-file.md",
"content": "newly-added-test-file",
"commit_mail_id": "32",
})
session := loginUser(t, "user30")
req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`)
assert.Empty(t, doc.AttrOr("checked", "_no_"))
req = NewRequestWithValues(t, "POST", "/user30/empty/_new/"+setting.Repository.DefaultBranch, map[string]string{
"commit_choice": "direct",
"tree_path": "test-file.md",
"content": "newly-added-test-file",
"commit_mail_id": "32",
resp = session.MakeRequest(t, req, http.StatusSeeOther)
redirect := test.RedirectURL(resp)
assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/test-file.md", redirect)
req = NewRequest(t, "GET", redirect)
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "newly-added-test-file")
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
redirect := test.RedirectURL(resp)
assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/test-file.md", redirect)
req = NewRequest(t, "GET", redirect)
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "newly-added-test-file")
}
func TestEmptyRepoUploadFile(t *testing.T) {
defer tests.PrepareTestEnv(t)()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user30")
req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`)
assert.Empty(t, doc.AttrOr("checked", "_no_"))
session := loginUser(t, "user30")
req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`)
assert.Empty(t, doc.AttrOr("checked", "_no_"))
body := &bytes.Buffer{}
mpForm := multipart.NewWriter(body)
file, _ := mpForm.CreateFormFile("file", "uploaded-file.txt")
_, _ = io.Copy(file, bytes.NewBufferString("newly-uploaded-test-file"))
_ = mpForm.Close()
body := &bytes.Buffer{}
mpForm := multipart.NewWriter(body)
file, _ := mpForm.CreateFormFile("file", "uploaded-file.txt")
_, _ = io.Copy(file, bytes.NewBufferString("newly-uploaded-test-file"))
_ = mpForm.Close()
req = NewRequestWithBody(t, "POST", "/user30/empty/upload-file", body)
req.Header.Add("Content-Type", mpForm.FormDataContentType())
resp = session.MakeRequest(t, req, http.StatusOK)
respMap := map[string]string{}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &respMap))
filesFullpathKey := fmt.Sprintf("files_fullpath[%s]", respMap["uuid"])
req = NewRequestWithValues(t, "POST", "/user30/empty/_upload/"+setting.Repository.DefaultBranch, map[string]string{
"commit_choice": "direct",
"files": respMap["uuid"],
filesFullpathKey: "uploaded-file.txt",
"tree_path": "",
"commit_mail_id": "-1",
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
redirect := test.RedirectURL(resp)
assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/", redirect)
req = NewRequestWithBody(t, "POST", "/user30/empty/upload-file", body)
req.Header.Add("Content-Type", mpForm.FormDataContentType())
resp = session.MakeRequest(t, req, http.StatusOK)
respMap := map[string]string{}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &respMap))
filesFullpathKey := fmt.Sprintf("files_fullpath[%s]", respMap["uuid"])
req = NewRequestWithValues(t, "POST", "/user30/empty/_upload/"+setting.Repository.DefaultBranch, map[string]string{
"commit_choice": "direct",
"files": respMap["uuid"],
filesFullpathKey: "uploaded-file.txt",
"tree_path": "",
"commit_mail_id": "-1",
req = NewRequest(t, "GET", redirect)
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "uploaded-file.txt")
})
resp = session.MakeRequest(t, req, http.StatusSeeOther)
redirect := test.RedirectURL(resp)
assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/", redirect)
req = NewRequest(t, "GET", redirect)
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "uploaded-file.txt")
}
func TestEmptyRepoAddFileByAPI(t *testing.T) {
defer tests.PrepareTestEnv(t)()
onApplicationRun(t, func(t *testing.T, _ *url.URL) {
session := loginUser(t, "user30")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
session := loginUser(t, "user30")
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user30/empty/contents/new-file.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "new_branch",
Message: "init",
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("newly-added-api-file")),
}).AddTokenAuth(token)
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user30/empty/contents/new-file.txt", &api.CreateFileOptions{
FileOptions: api.FileOptions{
NewBranchName: "new_branch",
Message: "init",
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("newly-added-api-file")),
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
var fileResponse api.FileResponse
DecodeJSON(t, resp, &fileResponse)
expectedHTMLURL := setting.AppURL + "user30/empty/src/branch/new_branch/new-file.txt"
assert.Equal(t, expectedHTMLURL, *fileResponse.Content.HTMLURL)
resp := MakeRequest(t, req, http.StatusCreated)
var fileResponse api.FileResponse
DecodeJSON(t, resp, &fileResponse)
expectedHTMLURL := setting.AppURL + "user30/empty/src/branch/new_branch/new-file.txt"
assert.Equal(t, expectedHTMLURL, *fileResponse.Content.HTMLURL)
req = NewRequest(t, "GET", "/user30/empty/src/branch/new_branch/new-file.txt")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "newly-added-api-file")
req = NewRequest(t, "GET", "/user30/empty/src/branch/new_branch/new-file.txt")
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "newly-added-api-file")
req = NewRequest(t, "GET", "/api/v1/repos/user30/empty").
AddTokenAuth(token)
resp = session.MakeRequest(t, req, http.StatusOK)
var apiRepo api.Repository
DecodeJSON(t, resp, &apiRepo)
assert.Equal(t, "new_branch", apiRepo.DefaultBranch)
req = NewRequest(t, "GET", "/api/v1/repos/user30/empty").
AddTokenAuth(token)
resp = session.MakeRequest(t, req, http.StatusOK)
var apiRepo api.Repository
DecodeJSON(t, resp, &apiRepo)
assert.Equal(t, "new_branch", apiRepo.DefaultBranch)
})
}
func TestEmptyRepoAPIRequestsReturn404(t *testing.T) {

View file

@ -0,0 +1,99 @@
// Copyright 2026 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/url"
"os"
"path"
"testing"
"forgejo.org/models/auth"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"github.com/stretchr/testify/require"
)
func TestCustomGitHooks(t *testing.T) {
onApplicationRun(t, func(t *testing.T, u *url.URL) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
httpContext := NewAPITestContext(t, owner.Name, repo.Name, auth.AccessTokenScopeReadRepository)
dstPath := t.TempDir()
u.Path = httpContext.GitPath()
u.User = url.UserPassword(owner.Name, userPassword)
doGitClone(dstPath, u)(t)
customHooksDir := path.Join(repo.RepoPath(), "hooks")
hookNames := []string{"pre-receive", "update", "post-receive"}
for _, hookName := range hookNames {
customPath := path.Join(customHooksDir, hookName+".d")
err := os.MkdirAll(customPath, 0x755)
require.NoError(t, err)
err = os.WriteFile(path.Join(customPath, "append-proof"), customGitHookTpl(hookName), 0x755)
require.NoError(t, err)
// The legacy, already existing gitea script might be there in the hooks directory in old installations,
// here it's ensured that these scripts filtered out when custom hooks run
err = os.WriteFile(path.Join(customPath, "gitea"), customGitHookGiteaTpl(), 0x755)
require.NoError(t, err)
}
fd, err := os.Create(path.Join(dstPath, "hooks-test.txt"))
require.NoError(t, err)
err = fd.Close()
require.NoError(t, err)
_, _, err = git.NewCommand(git.DefaultContext, "checkout", "master").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
err = os.WriteFile(path.Join(dstPath, "hooks-test.txt"), []byte("test"), 0x644)
require.NoError(t, err)
_, _, err = git.NewCommand(git.DefaultContext, "add", "hooks-test.txt").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
_, _, err = git.NewCommand(git.DefaultContext, "commit", "-m", "Add hooks-test.txt").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
_, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "master").RunStdString(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)
data, err := os.ReadFile(path.Join(customHooksDir, "hooks-proof.txt"))
require.NoError(t, err)
require.Equal(t, `pre-receive
update
post-receive
`, string(data))
})
}
func customGitHookTpl(hookName string) []byte {
hookStr := fmt.Sprintf(`#!/usr/bin/env sh
echo "%s" >> $(dirname $0)/../hooks-proof.txt
`, hookName)
return []byte(hookStr)
}
func customGitHookGiteaTpl() []byte {
hookStr := `#!/usr/bin/env sh
echo "legacy gitea script shouldn't be called!"
exit 1
`
return []byte(hookStr)
}

View file

@ -201,6 +201,7 @@ func standardCommitAndPushTest(t *testing.T, dstPath string) (little, big string
func lfsCommitAndPushTest(t *testing.T, dstPath string) (littleLFS, bigLFS string) {
t.Run("LFS", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer git.NewCommand(git.DefaultContext, "lfs").AddArguments("uninstall").Run(&git.RunOpts{Dir: dstPath})
prefix := "lfs-data-file-"
err := git.NewCommand(git.DefaultContext, "lfs").AddArguments("install").Run(&git.RunOpts{Dir: dstPath})
require.NoError(t, err)

View file

@ -5,29 +5,29 @@ package integration
import (
"net/http"
"net/url"
"testing"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
)
func TestRepoMergeCommitRevert(t *testing.T) {
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
onApplicationRun(t, func(t *testing.T, _ *url.URL) {
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user2/test_commit_revert/_cherrypick/deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7/main", map[string]string{
"last_commit": "deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7",
"page_has_posted": "true",
"revert": "true",
"commit_summary": "reverting test commit",
"commit_message": "test message",
"commit_choice": "direct",
"new_branch_name": "test-revert-branch-1",
"commit_mail_id": "-1",
req := NewRequestWithValues(t, "POST", "/user2/test_commit_revert/_cherrypick/deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7/main", map[string]string{
"last_commit": "deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7",
"page_has_posted": "true",
"revert": "true",
"commit_summary": "reverting test commit",
"commit_message": "test message",
"commit_choice": "direct",
"new_branch_name": "test-revert-branch-1",
"commit_mail_id": "-1",
})
resp := session.MakeRequest(t, req, http.StatusSeeOther)
// A successful revert redirects to the main branch
assert.Equal(t, "/user2/test_commit_revert/src/branch/main", resp.Header().Get("Location"))
})
resp := session.MakeRequest(t, req, http.StatusSeeOther)
// A successful revert redirects to the main branch
assert.Equal(t, "/user2/test_commit_revert/src/branch/main", resp.Header().Get("Location"))
}

2
tests/unittest.ini.tmpl Normal file
View file

@ -0,0 +1,2 @@
[security]
INSTALL_LOCK = true