diff --git a/tests/forgery/fs.go b/tests/forgery/fs.go new file mode 100644 index 0000000000..f41b193702 --- /dev/null +++ b/tests/forgery/fs.go @@ -0,0 +1,112 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgery + +import ( + "fmt" + "io" + "io/fs" + "strings" + "testing/fstest" + "time" + + repo_model "forgejo.org/models/repo" + user_model "forgejo.org/models/user" + "forgejo.org/modules/git" + files_service "forgejo.org/services/repository/files" +) + +type MapFS = fstest.MapFS + +// when a file has one of this mode, it needs specific git handling +// other non-zero modes are rejected +const ( + modeSubmodule = fs.ModeNamedPipe // hacky, but + modeSymlink = fs.ModeSymlink +) + +func MapFile(data string) *fstest.MapFile { + return &fstest.MapFile{ + Data: []byte(data), + } +} + +func MapSymlink(target string) *fstest.MapFile { + return &fstest.MapFile{ + Data: []byte(target), + Mode: modeSymlink, + } +} + +func MapSubmodule(sha string) *fstest.MapFile { + return &fstest.MapFile{ + Data: []byte(sha), + Mode: modeSubmodule, + } +} + +func initRepo(doer *user_model.User, repo *repo_model.Repository, format git.ObjectFormat, fsys fs.FS, commitMessage string) (string, error) { + t, err := files_service.NewTemporaryUploadRepository(git.DefaultContext, repo) + if err != nil { + return "", err + } + defer t.Close() + if err := t.Init(format.Name()); err != nil { + return "", err + } + + if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + + var content io.Reader + mode := git.EntryModeBlob + switch d.Type() { + case modeSymlink: + target, err := fs.ReadLink(fsys, path) + if err != nil { + return err + } + content = strings.NewReader(target) + mode = git.EntryModeSymlink + case modeSubmodule: + mode = git.EntryModeCommit + fallthrough + case 0: + f, err := fsys.Open(path) + if err != nil { + return err + } + defer f.Close() + + content = f + default: + return fmt.Errorf("unexpected file type in forgery.CreateRepository %s: %s", path, d.Type()) + } + + // add object to the database + objectHash, err := t.HashObject(content) + if err != nil { + return err + } + // Add the object to the index + return t.AddObjectToIndex(mode.String(), objectHash, path) + }); err != nil { + return "", err + } + + treeHash, err := t.WriteTree() + if err != nil { + return "", err + } + + now := time.Now() + commitHash, err := t.CommitTreeWithDate("", doer, doer, treeHash, commitMessage, false, now, now) + if err != nil { + return "", err + } + + return commitHash, t.Push(doer, commitHash, repo.DefaultBranch) +} diff --git a/tests/forgery/repo.go b/tests/forgery/repo.go new file mode 100644 index 0000000000..bfa0a51681 --- /dev/null +++ b/tests/forgery/repo.go @@ -0,0 +1,138 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgery + +import ( + "cmp" + "io/fs" + "testing" + + repo_model "forgejo.org/models/repo" + unit_model "forgejo.org/models/unit" + user_model "forgejo.org/models/user" + "forgejo.org/modules/git" + repo_service "forgejo.org/services/repository" + wiki_service "forgejo.org/services/wiki" + + "github.com/stretchr/testify/require" + "xorm.io/xorm/convert" +) + +type CreateRepositoryOptions struct { + Name string // if nil a unique name (derived from the test name) will be generated + + // Content of the initial commit, if nil the git repo will be left uninitialized. + // Use [MapFS] or [FilesInit] to setup the initial files. + Files fs.FS + + ObjectFormat git.ObjectFormat // If nil, SHA1 + IsTemplate bool + IsPrivate bool + + LatestSha *string // if not nil, the commit sha after initializing the repo with the Files will be written to this ref + SkipCleanup bool // if true the repo will not be deleted at the end of the test (can be useful to debug locally) +} + +// FilesInit specifies the templates to use upon repository initialization. +type FilesInit struct { + Readme string + Gitignores string + License string +} + +func (FilesInit) Open(name string) (fs.File, error) { + panic("FilesInit is only a sentinel value") +} + +// CreateRepository returns the repo, owner and opts can be nil +func CreateRepository(t testing.TB, owner *user_model.User, opts *CreateRepositoryOptions) *repo_model.Repository { + t.Helper() + + if owner == nil { + owner = CreateUser(t, nil) // if specific options are needed, create the owner manually + } + if opts == nil { + opts = &CreateRepositoryOptions{} + } + + repoName := opts.Name + if repoName == "" { + repoName = "repo-" + uniqueSafeName(t.Name()) + } + + gitFormat := cmp.Or(opts.ObjectFormat, git.Sha1ObjectFormat) + + // Create the repository + createOptions := repo_service.CreateRepoOptions{ + Name: repoName, + Description: "Test Repo", + DefaultBranch: "main", + IsTemplate: opts.IsTemplate, + ObjectFormatName: gitFormat.Name(), + IsPrivate: opts.IsPrivate, + } + if fi, ok := opts.Files.(FilesInit); ok { + createOptions.AutoInit = true + createOptions.Readme = cmp.Or(fi.Readme, "Default") + createOptions.Gitignores = fi.Gitignores + createOptions.License = fi.License + } + repo, err := repo_service.CreateRepositoryDirectly(t.Context(), owner, owner, createOptions) + require.NoError(t, err) + if !opts.SkipCleanup { + t.Cleanup(func() { + _ = repo_service.DeleteRepository(t.Context(), owner, repo, false) + }) + } + require.NotEmpty(t, repo) + + if !createOptions.AutoInit && opts.Files != nil { + sha, err := initRepo(owner, repo, gitFormat, opts.Files, "init") + require.NoError(t, err) + if opts.LatestSha != nil { + *opts.LatestSha = sha + } + + // reload the repo since pushing a commit might update the model via the push_update queue (IsEmpty for instance) + repo, err = repo_model.GetRepositoryByID(t.Context(), repo.ID) + require.NoError(t, err) + } + repo.Owner = owner + + return repo +} + +func InitWiki(t testing.TB, repo *repo_model.Repository, branch string) { + // Set the wiki branch in the database first + repo.WikiBranch = branch + err := repo_model.UpdateRepositoryCols(t.Context(), repo, "wiki_branch") + require.NoError(t, err) + + // Initialize the wiki + err = wiki_service.InitWiki(t.Context(), repo) + require.NoError(t, err) + + // Add a new wiki page + err = wiki_service.AddWikiPage(t.Context(), repo.Owner, repo, "Home", "Welcome to the wiki!", "Add a Home page") + require.NoError(t, err) +} + +// config may be nil +func EnableRepoUnit(t testing.TB, repo *repo_model.Repository, unit unit_model.Type, config convert.Conversion) { + t.Helper() + + err := repo_service.UpdateRepositoryUnits(t.Context(), repo, []repo_model.RepoUnit{{ + RepoID: repo.ID, + Type: unit, + Config: config, + }}, nil) + require.NoError(t, err) +} + +func DisableRepoUnits(t testing.TB, repo *repo_model.Repository, units ...unit_model.Type) { + t.Helper() + + err := repo_service.UpdateRepositoryUnits(t.Context(), repo, nil, units) + require.NoError(t, err) +} diff --git a/tests/forgery/user.go b/tests/forgery/user.go new file mode 100644 index 0000000000..1175297497 --- /dev/null +++ b/tests/forgery/user.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgery + +import ( + "math/rand/v2" + "regexp" + "strconv" + "testing" + + org_model "forgejo.org/models/organization" + user_model "forgejo.org/models/user" + + "github.com/stretchr/testify/require" +) + +var nameCleaner = regexp.MustCompile(`[^a-zA-Z0-9-]+`) // exclude "_", to prevent multiple consecutive dashes + +// uniqueSafeName replaces specials chars with _ and appends a random hex suffix +func uniqueSafeName(testName string) string { + return nameCleaner.ReplaceAllLiteralString(testName, "_") + "-" + strconv.FormatUint(uint64(rand.Uint32()), 16) +} + +type CreateUserOptions struct { + IsAdmin bool +} + +const userPassword = "password" + +func CreateUser(t testing.TB, opts *CreateUserOptions) *user_model.User { + t.Helper() + + if opts == nil { + opts = &CreateUserOptions{} + } + u := &user_model.User{} + + name := "user-" + uniqueSafeName(t.Name()) + + u.Name = name + u.Email = name + "@test.forgejo.org" + u.Passwd = userPassword + u.IsAdmin = opts.IsAdmin + + err := user_model.CreateUser(t.Context(), u) + require.NoError(t, err) + return u +} + +func CreateOrganisation(t testing.TB, owner *user_model.User) *org_model.Organization { + t.Helper() + + if owner == nil { + owner = CreateUser(t, nil) // if specific options are needed, create the owner manually + } + o := &org_model.Organization{} + + name := "org-" + uniqueSafeName(t.Name()) + + o.Name = name + + err := org_model.CreateOrganization(t.Context(), o, owner) + require.NoError(t, err) + return o +}