API: enforce sha requirement on POST /repos/{owner}/{repo}/contents (#8139)

Currently the `POST /repos/{owner}/{repo}/contents` API endpoint accepts request without any `ChangeFileOperation.SHA`, unlike stated by the doc:
33eee199cf/modules/structs/repo_file.go (L80-L81)

This PR adds:
- some more (already passing) tests around this function
- a new (failing) test to show this wrong behavior
- a fix (note that this is a breaking change for clients exploiting this bug)
- an update for all the existing tests

<!--start release-notes-assistant-->

## Release notes
<!--URL:https://codeberg.org/forgejo/forgejo-->
- Breaking bug fixes
  - [PR](https://codeberg.org/forgejo/forgejo/pulls/8139): <!--number 8139 --><!--line 0 --><!--description QVBJOiBlbmZvcmNlIHNoYSByZXF1aXJlbWVudCBvbiBgUE9TVCAvcmVwb3Mve293bmVyfS97cmVwb30vY29udGVudHNg-->API: enforce sha requirement on `POST /repos/{owner}/{repo}/contents`<!--description-->
<!--end release-notes-assistant-->

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8139
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: oliverpool <git@olivier.pfad.fr>
Co-committed-by: oliverpool <git@olivier.pfad.fr>
This commit is contained in:
oliverpool 2025-06-12 00:13:39 +02:00 committed by Earl Warren
parent d3bc095d0c
commit c93eb1f927
13 changed files with 170 additions and 86 deletions

View file

@ -414,7 +414,7 @@ func IsErrSHAOrCommitIDNotProvided(err error) bool {
} }
func (err ErrSHAOrCommitIDNotProvided) Error() string { func (err ErrSHAOrCommitIDNotProvided) Error() string {
return "a SHA or commit ID must be proved when updating a file" return "a SHA or commit ID must be provided when updating a file"
} }
// ErrInvalidMergeStyle represents an error if merging with disabled merge strategy // ErrInvalidMergeStyle represents an error if merging with disabled merge strategy

View file

@ -480,6 +480,8 @@ func ChangeFiles(ctx *context.APIContext) {
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/conflict"
// "413": // "413":
// "$ref": "#/responses/quotaExceeded" // "$ref": "#/responses/quotaExceeded"
// "422": // "422":
@ -584,6 +586,8 @@ func CreateFile(ctx *context.APIContext) {
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/conflict"
// "413": // "413":
// "$ref": "#/responses/quotaExceeded" // "$ref": "#/responses/quotaExceeded"
// "422": // "422":
@ -684,6 +688,8 @@ func UpdateFile(ctx *context.APIContext) {
// "$ref": "#/responses/error" // "$ref": "#/responses/error"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
// "409":
// "$ref": "#/responses/conflict"
// "413": // "413":
// "$ref": "#/responses/quotaExceeded" // "$ref": "#/responses/quotaExceeded"
// "422": // "422":
@ -757,11 +763,19 @@ func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) {
ctx.Error(http.StatusForbidden, "Access", err) ctx.Error(http.StatusForbidden, "Access", err)
return return
} }
if git_model.IsErrBranchAlreadyExists(err) || models.IsErrFilenameInvalid(err) || models.IsErrSHADoesNotMatch(err) || if git_model.IsErrBranchAlreadyExists(err) ||
models.IsErrFilePathInvalid(err) || models.IsErrRepoFileAlreadyExists(err) { models.IsErrFilenameInvalid(err) ||
models.IsErrSHAOrCommitIDNotProvided(err) ||
models.IsErrFilePathInvalid(err) ||
models.IsErrRepoFileAlreadyExists(err) {
ctx.Error(http.StatusUnprocessableEntity, "Invalid", err) ctx.Error(http.StatusUnprocessableEntity, "Invalid", err)
return return
} }
if models.IsErrCommitIDDoesNotMatch(err) ||
models.IsErrSHADoesNotMatch(err) {
ctx.Error(http.StatusConflict, "Conflict", err)
return
}
if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) { if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) {
ctx.Error(http.StatusNotFound, "BranchDoesNotExist", err) ctx.Error(http.StatusNotFound, "BranchDoesNotExist", err)
return return

View file

@ -193,28 +193,34 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
} }
if hasOldBranch { if hasOldBranch {
// Get the commit of the original branch // Get the current commit of the original branch
commit, err := t.GetBranchCommit(opts.OldBranch) actualBaseCommit, err := t.GetBranchCommit(opts.OldBranch)
if err != nil { if err != nil {
return nil, err // Couldn't get a commit for the branch return nil, err // Couldn't get a commit for the branch
} }
// Assigned LastCommitID in opts if it hasn't been set var lastKnownCommit git.ObjectID // when nil, the sha provided in the opts.Files must match the current blob-sha
if opts.LastCommitID == "" { if opts.OldBranch != opts.NewBranch {
opts.LastCommitID = commit.ID.String() // when creating a new branch, ignore if a file has been changed in the meantime
} else { // (such changes will visible when doing the merge)
lastCommitID, err := t.gitRepo.ConvertToGitID(opts.LastCommitID) lastKnownCommit = actualBaseCommit.ID
} else if opts.LastCommitID != "" {
lastKnownCommit, err = t.gitRepo.ConvertToGitID(opts.LastCommitID)
if err != nil { if err != nil {
return nil, fmt.Errorf("ConvertToSHA1: Invalid last commit ID: %w", err) return nil, fmt.Errorf("ConvertToSHA1: Invalid last commit ID: %w", err)
} }
opts.LastCommitID = lastCommitID.String()
} }
for _, file := range opts.Files { for _, file := range opts.Files {
if err := handleCheckErrors(file, commit, opts); err != nil { if err := handleCheckErrors(file, actualBaseCommit, lastKnownCommit); err != nil {
return nil, err return nil, err
} }
} }
if opts.LastCommitID == "" {
// needed for t.CommitTree
opts.LastCommitID = actualBaseCommit.ID.String()
}
} }
contentStore := lfs.NewContentStore() contentStore := lfs.NewContentStore()
@ -277,9 +283,9 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
} }
// handles the check for various issues for ChangeRepoFiles // handles the check for various issues for ChangeRepoFiles
func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRepoFilesOptions) error { func handleCheckErrors(file *ChangeRepoFile, actualBaseCommit *git.Commit, lastKnownCommit git.ObjectID) error {
if file.Operation == "update" || file.Operation == "delete" { if file.Operation == "update" || file.Operation == "delete" {
fromEntry, err := commit.GetTreeEntryByPath(file.Options.fromTreePath) fromEntry, err := actualBaseCommit.GetTreeEntryByPath(file.Options.fromTreePath)
if err != nil { if err != nil {
return err return err
} }
@ -292,22 +298,22 @@ func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRep
CurrentSHA: fromEntry.ID.String(), CurrentSHA: fromEntry.ID.String(),
} }
} }
} else if opts.LastCommitID != "" { } else if lastKnownCommit != nil {
// If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw if actualBaseCommit.ID.String() != lastKnownCommit.String() {
// an error, but only if we aren't creating a new branch. // If a lastKnownCommit was given and it doesn't match the actualBaseCommit,
if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch { // check if the file has been changed in between
if changed, err := commit.FileChangedSinceCommit(file.Options.treePath, opts.LastCommitID); err != nil { if changed, err := actualBaseCommit.FileChangedSinceCommit(file.Options.treePath, lastKnownCommit.String()); err != nil {
return err return err
} else if changed { } else if changed {
return models.ErrCommitIDDoesNotMatch{ return models.ErrCommitIDDoesNotMatch{
GivenCommitID: opts.LastCommitID, GivenCommitID: lastKnownCommit.String(),
CurrentCommitID: opts.LastCommitID, CurrentCommitID: actualBaseCommit.ID.String(),
} }
} }
// The file wasn't modified, so we are good to delete it // The file wasn't modified, so we are good to update it
} }
} else { } else {
// When updating a file, a lastCommitID or SHA needs to be given to make sure other commits // When updating a file, a lastKnownCommit or SHA needs to be given to make sure other commits
// haven't been made. We throw an error if one wasn't provided. // haven't been made. We throw an error if one wasn't provided.
return models.ErrSHAOrCommitIDNotProvided{} return models.ErrSHAOrCommitIDNotProvided{}
} }
@ -322,7 +328,7 @@ func handleCheckErrors(file *ChangeRepoFile, commit *git.Commit, opts *ChangeRep
subTreePath := "" subTreePath := ""
for index, part := range treePathParts { for index, part := range treePathParts {
subTreePath = path.Join(subTreePath, part) subTreePath = path.Join(subTreePath, part)
entry, err := commit.GetTreeEntryByPath(subTreePath) entry, err := actualBaseCommit.GetTreeEntryByPath(subTreePath)
if err != nil { if err != nil {
if git.IsErrNotExist(err) { if git.IsErrNotExist(err) {
// Means there is no item with that name, so we're good // Means there is no item with that name, so we're good

View file

@ -6897,6 +6897,9 @@
"404": { "404": {
"$ref": "#/responses/notFound" "$ref": "#/responses/notFound"
}, },
"409": {
"$ref": "#/responses/conflict"
},
"413": { "413": {
"$ref": "#/responses/quotaExceeded" "$ref": "#/responses/quotaExceeded"
}, },
@ -7010,6 +7013,9 @@
"404": { "404": {
"$ref": "#/responses/notFound" "$ref": "#/responses/notFound"
}, },
"409": {
"$ref": "#/responses/conflict"
},
"413": { "413": {
"$ref": "#/responses/quotaExceeded" "$ref": "#/responses/quotaExceeded"
}, },
@ -7074,6 +7080,9 @@
"404": { "404": {
"$ref": "#/responses/notFound" "$ref": "#/responses/notFound"
}, },
"409": {
"$ref": "#/responses/conflict"
},
"413": { "413": {
"$ref": "#/responses/quotaExceeded" "$ref": "#/responses/quotaExceeded"
}, },

View file

@ -74,6 +74,7 @@ func newRepo(t *testing.T, userID int64, repoName string, fileChanges []FileChan
nil, nil,
) )
var lastCommitID string
for _, file := range fileChanges { for _, file := range fileChanges {
for i, version := range file.Versions { for i, version := range file.Versions {
operation := "update" operation := "update"
@ -108,9 +109,12 @@ func newRepo(t *testing.T, userID int64, repoName string, fileChanges []FileChan
Author: time.Now(), Author: time.Now(),
Committer: time.Now(), Committer: time.Now(),
}, },
LastCommitID: lastCommitID,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp) assert.NotEmpty(t, resp)
lastCommitID = resp.Commit.SHA
} }
} }

View file

@ -155,7 +155,7 @@ func TestAPIRepoIssueConfigPaths(t *testing.T) {
assert.False(t, issueConfig.BlankIssuesEnabled) assert.False(t, issueConfig.BlankIssuesEnabled)
assert.Empty(t, issueConfig.ContactLinks) assert.Empty(t, issueConfig.ContactLinks)
_, err = deleteFileInBranch(owner, repo, fullPath, repo.DefaultBranch) err = deleteFileInBranch(owner, repo, fullPath, repo.DefaultBranch)
require.NoError(t, err) require.NoError(t, err)
}) })
} }

View file

@ -47,9 +47,7 @@ func TestAPIIssueTemplateList(t *testing.T) {
for _, template := range templateCandidates { for _, template := range templateCandidates {
t.Run(template, func(t *testing.T) { t.Run(template, func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
defer func() { defer deleteFileInBranch(user, repo, template, repo.DefaultBranch)
deleteFileInBranch(user, repo, template, repo.DefaultBranch)
}()
err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch, err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch,
`--- `---

View file

@ -10,6 +10,7 @@ import (
repo_model "forgejo.org/models/repo" repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/git" "forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
api "forgejo.org/modules/structs" api "forgejo.org/modules/structs"
files_service "forgejo.org/services/repository/files" files_service "forgejo.org/services/repository/files"
) )
@ -30,7 +31,12 @@ func createFileInBranch(user *user_model.User, repo *repo_model.Repository, tree
return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts) return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts)
} }
func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName string) (*api.FilesResponse, error) { func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName string) error {
commitID, err := gitrepo.GetBranchCommitID(git.DefaultContext, repo, branchName)
if err != nil {
return err
}
opts := &files_service.ChangeRepoFilesOptions{ opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{ Files: []*files_service.ChangeRepoFile{
{ {
@ -41,13 +47,14 @@ func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, tree
OldBranch: branchName, OldBranch: branchName,
Author: nil, Author: nil,
Committer: nil, Committer: nil,
LastCommitID: commitID,
} }
return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts) _, err = files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts)
return err
} }
func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error { func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error {
_, err := deleteFileInBranch(user, repo, treePath, branchName) err := deleteFileInBranch(user, repo, treePath, branchName)
if err != nil && !models.IsErrRepoFileDoesNotExist(err) { if err != nil && !models.IsErrRepoFileDoesNotExist(err) {
return err return err
} }

View file

@ -214,7 +214,7 @@ func TestAPIUpdateFile(t *testing.T) {
updateFileOptions.SHA = "badsha" updateFileOptions.SHA = "badsha"
req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions). req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions).
AddTokenAuth(token2) AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusUnprocessableEntity) resp = MakeRequest(t, req, http.StatusConflict)
expectedAPIError := context.APIError{ expectedAPIError := context.APIError{
Message: "sha does not match [given: " + updateFileOptions.SHA + ", expected: " + correctSHA + "]", Message: "sha does not match [given: " + updateFileOptions.SHA + ", expected: " + correctSHA + "]",
URL: setting.API.SwaggerURL, URL: setting.API.SwaggerURL,

View file

@ -214,7 +214,7 @@ func TestAPIChangeFiles(t *testing.T) {
changeFilesOptions.Files[0].SHA = "badsha" changeFilesOptions.Files[0].SHA = "badsha"
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions). req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2) AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusUnprocessableEntity) resp = MakeRequest(t, req, http.StatusConflict)
expectedAPIError := context.APIError{ expectedAPIError := context.APIError{
Message: "sha does not match [given: " + changeFilesOptions.Files[0].SHA + ", expected: " + correctSHA + "]", Message: "sha does not match [given: " + changeFilesOptions.Files[0].SHA + ", expected: " + correctSHA + "]",
URL: setting.API.SwaggerURL, URL: setting.API.SwaggerURL,

View file

@ -6,6 +6,7 @@ package integration
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@ -19,6 +20,7 @@ import (
repo_model "forgejo.org/models/repo" repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest" "forgejo.org/models/unittest"
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"forgejo.org/modules/gitrepo" "forgejo.org/modules/gitrepo"
repo_module "forgejo.org/modules/repository" repo_module "forgejo.org/modules/repository"
"forgejo.org/modules/test" "forgejo.org/modules/test"
@ -93,16 +95,9 @@ func TestPullView_SelfReviewNotification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// create a new branch to prepare for pull request // create a new branch to prepare for pull request
_, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ err = updateFileInBranch(user2, repo, "README.md", "codeowner-basebranch",
NewBranch: "codeowner-basebranch", strings.NewReader("# This is a new project\n"),
Files: []*files_service.ChangeRepoFile{ )
{
Operation: "update",
TreePath: "README.md",
ContentReader: strings.NewReader("# This is a new project\n"),
},
},
})
require.NoError(t, err) require.NoError(t, err)
// Create a pull request. // Create a pull request.
@ -366,16 +361,9 @@ func TestPullView_CodeOwner(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
// create a new branch to prepare for pull request // create a new branch to prepare for pull request
_, err := files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ err := updateFileInBranch(user2, repo, "README.md", "codeowner-basebranch",
NewBranch: "codeowner-basebranch", strings.NewReader("# This is a new project\n"),
Files: []*files_service.ChangeRepoFile{ )
{
Operation: "update",
TreePath: "README.md",
ContentReader: strings.NewReader("# This is a new project\n"),
},
},
})
require.NoError(t, err) require.NoError(t, err)
// Create a pull request. // Create a pull request.
@ -400,31 +388,18 @@ func TestPullView_CodeOwner(t *testing.T) {
}) })
// change the default branch CODEOWNERS file to change README.md's codeowner // change the default branch CODEOWNERS file to change README.md's codeowner
_, err := files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ err := updateFileInBranch(user2, repo, "CODEOWNERS", "",
Files: []*files_service.ChangeRepoFile{ strings.NewReader("README.md @user8\n"),
{ )
Operation: "update",
TreePath: "CODEOWNERS",
ContentReader: strings.NewReader("README.md @user8\n"),
},
},
})
require.NoError(t, err) require.NoError(t, err)
t.Run("Second Pull Request", func(t *testing.T) { t.Run("Second Pull Request", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
// create a new branch to prepare for pull request // create a new branch to prepare for pull request
_, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{ err := updateFileInBranch(user2, repo, "README.md", "codeowner-basebranch2",
NewBranch: "codeowner-basebranch2", strings.NewReader("# This is a new project2\n"),
Files: []*files_service.ChangeRepoFile{ )
{
Operation: "update",
TreePath: "README.md",
ContentReader: strings.NewReader("# This is a new project2\n"),
},
},
})
require.NoError(t, err) require.NoError(t, err)
// Create a pull request. // Create a pull request.
@ -446,16 +421,9 @@ func TestPullView_CodeOwner(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// create a new branch to prepare for pull request // create a new branch to prepare for pull request
_, err = files_service.ChangeRepoFiles(db.DefaultContext, forkedRepo, user5, &files_service.ChangeRepoFilesOptions{ err = updateFileInBranch(user5, forkedRepo, "README.md", "codeowner-basebranch-forked",
NewBranch: "codeowner-basebranch-forked", strings.NewReader("# This is a new forked project\n"),
Files: []*files_service.ChangeRepoFile{ )
{
Operation: "update",
TreePath: "README.md",
ContentReader: strings.NewReader("# This is a new forked project\n"),
},
},
})
require.NoError(t, err) require.NoError(t, err)
session := loginUser(t, "user5") session := loginUser(t, "user5")
@ -762,3 +730,32 @@ func TestPullRequestReplyMail(t *testing.T) {
}) })
}) })
} }
func updateFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, newBranch string, content io.ReadSeeker) error {
oldBranch, err := gitrepo.GetDefaultBranch(git.DefaultContext, repo)
if err != nil {
return err
}
commitID, err := gitrepo.GetBranchCommitID(git.DefaultContext, repo, oldBranch)
if err != nil {
return err
}
opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "update",
TreePath: treePath,
ContentReader: content,
},
},
OldBranch: oldBranch,
NewBranch: newBranch,
Author: nil,
Committer: nil,
LastCommitID: commitID,
}
_, err = files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts)
return err
}

View file

@ -294,6 +294,30 @@ func TestChangeRepoFiles(t *testing.T) {
assert.Equal(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name) assert.Equal(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
}) })
t.Run("Update with commit ID (without blob sha)", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
opts := getUpdateRepoFilesOptions(repo)
commit, err := gitRepo.GetBranchCommit(opts.NewBranch)
require.NoError(t, err)
opts.Files[0].SHA = ""
opts.LastCommitID = commit.ID.String()
filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
require.NoError(t, err)
commit, err = gitRepo.GetBranchCommit(opts.NewBranch)
require.NoError(t, err)
lastCommit, err := commit.GetCommitByPath(opts.Files[0].TreePath)
require.NoError(t, err)
expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String(), lastCommit.Committer.When)
assert.Equal(t, expectedFileResponse.Content, filesResponse.Files[0])
assert.Equal(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
assert.Equal(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
assert.Equal(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
assert.Equal(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
})
t.Run("Update and move", func(t *testing.T) { t.Run("Update and move", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
opts := getUpdateRepoFilesOptions(repo) opts := getUpdateRepoFilesOptions(repo)
@ -415,6 +439,26 @@ func TestChangeRepoFilesErrors(t *testing.T) {
assert.EqualError(t, err, expectedError) assert.EqualError(t, err, expectedError)
}) })
t.Run("missing SHA", func(t *testing.T) {
opts := getUpdateRepoFilesOptions(repo)
opts.Files[0].SHA = ""
filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, filesResponse)
require.Error(t, err)
expectedError := "a SHA or commit ID must be provided when updating a file"
assert.EqualError(t, err, expectedError)
})
t.Run("bad last commit ID", func(t *testing.T) {
opts := getUpdateRepoFilesOptions(repo)
opts.LastCommitID = "bad"
filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
assert.Nil(t, filesResponse)
require.Error(t, err)
expectedError := "ConvertToSHA1: Invalid last commit ID: object does not exist [id: bad, rel_path: ]"
assert.EqualError(t, err, expectedError)
})
t.Run("new branch already exists", func(t *testing.T) { t.Run("new branch already exists", func(t *testing.T) {
opts := getUpdateRepoFilesOptions(repo) opts := getUpdateRepoFilesOptions(repo)
opts.NewBranch = "develop" opts.NewBranch = "develop"

View file

@ -24,6 +24,7 @@ import (
user_model "forgejo.org/models/user" user_model "forgejo.org/models/user"
"forgejo.org/modules/base" "forgejo.org/modules/base"
"forgejo.org/modules/git" "forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/graceful" "forgejo.org/modules/graceful"
"forgejo.org/modules/log" "forgejo.org/modules/log"
"forgejo.org/modules/optional" "forgejo.org/modules/optional"
@ -408,6 +409,9 @@ func CreateDeclarativeRepoWithOptions(t *testing.T, owner *user_model.User, opts
assert.True(t, autoInit, "Files cannot be specified if AutoInit is disabled") assert.True(t, autoInit, "Files cannot be specified if AutoInit is disabled")
files := opts.Files.Value() files := opts.Files.Value()
commitID, err := gitrepo.GetBranchCommitID(git.DefaultContext, repo, "main")
require.NoError(t, err)
resp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner, &files_service.ChangeRepoFilesOptions{ resp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner, &files_service.ChangeRepoFilesOptions{
Files: files, Files: files,
Message: "add files", Message: "add files",
@ -425,6 +429,7 @@ func CreateDeclarativeRepoWithOptions(t *testing.T, owner *user_model.User, opts
Author: time.Now(), Author: time.Now(),
Committer: time.Now(), Committer: time.Now(),
}, },
LastCommitID: commitID,
}) })
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp) assert.NotEmpty(t, resp)