Files
forge/tests/integration/api_repo_files_change_test.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

392 lines
19 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"testing"
auth_model "github.com/hanzoai/git/models/auth"
repo_model "github.com/hanzoai/git/models/repo"
"github.com/hanzoai/git/models/unittest"
user_model "github.com/hanzoai/git/models/user"
"github.com/hanzoai/git/modules/gitrepo"
"github.com/hanzoai/git/modules/setting"
api "github.com/hanzoai/git/modules/structs"
"github.com/hanzoai/git/services/context"
"github.com/stretchr/testify/assert"
)
func getChangeFilesOptions() *api.ChangeFilesOptions {
newContent := "This is new text"
updateContent := "This is updated text"
newContentEncoded := base64.StdEncoding.EncodeToString([]byte(newContent))
updateContentEncoded := base64.StdEncoding.EncodeToString([]byte(updateContent))
return &api.ChangeFilesOptions{
FileOptions: api.FileOptions{
BranchName: "master",
NewBranchName: "master",
Message: "My update of new/file.txt",
Author: api.Identity{
Name: "Anne Doe",
Email: "[email protected]",
},
Committer: api.Identity{
Name: "John Doe",
Email: "[email protected]",
},
},
Files: []*api.ChangeFileOperation{
{
Operation: "create",
ContentBase64: newContentEncoded,
},
{
Operation: "update",
ContentBase64: updateContentEncoded,
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
},
{
Operation: "delete",
SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
},
},
}
}
func TestAPIChangeFiles(t *testing.T) {
onGitRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
fileID := 0
// Get user2's token
session := loginUser(t, user2.Name)
token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
// Get user4's token
session = loginUser(t, user4.Name)
token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
// Test changing files in repo1 which user2 owns, try both with branch and empty branch
for _, branch := range []string{"master", ""} {
t.Run("Branch-"+branch, func(t *testing.T) {
fileID++
createTreePath := fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath := fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID)
_, _ = createFile(user2, repo1, updateTreePath)
_, _ = createFile(user2, repo1, deleteTreePath)
changeFilesOptions := getChangeFilesOptions()
changeFilesOptions.BranchName = branch
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo1.Name), &changeFilesOptions).
AddTokenAuth(token2)
resp := MakeRequest(t, req, http.StatusCreated)
gitRepo, _ := gitrepo.OpenRepository(t.Context(), repo1)
defer gitRepo.Close()
commitID, _ := gitRepo.GetBranchCommitID(changeFilesOptions.NewBranchName)
createLasCommit, _ := gitRepo.GetCommitByPath(createTreePath)
updateLastCommit, _ := gitRepo.GetCommitByPath(updateTreePath)
expectedCreateFileResponse := getExpectedFileResponseForCreate(apiFileResponseInfo{
repoFullName: fmt.Sprintf("%s/%s", user2.Name, repo1.Name),
commitID: commitID,
treePath: createTreePath,
lastCommitSHA: createLasCommit.ID.String(),
lastCommitterWhen: createLasCommit.Committer.When,
lastAuthorWhen: createLasCommit.Author.When,
})
expectedUpdateFileResponse := getExpectedFileResponseForUpdate(apiFileResponseInfo{
commitID: commitID,
treePath: updateTreePath,
lastCommitSHA: updateLastCommit.ID.String(),
lastCommitterWhen: updateLastCommit.Committer.When,
lastAuthorWhen: updateLastCommit.Author.When,
})
filesResponse := DecodeJSON(t, resp, &api.FilesResponse{})
normalizeFileContentResponseCommitTime(filesResponse.Files[0])
normalizeFileContentResponseCommitTime(filesResponse.Files[1])
assert.Equal(t, expectedCreateFileResponse.Content, filesResponse.Files[0]) // check create file
assert.Equal(t, expectedUpdateFileResponse.Content, filesResponse.Files[1]) // check update file
assert.Equal(t, expectedCreateFileResponse.Commit.SHA, filesResponse.Commit.SHA)
assert.Equal(t, expectedCreateFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
assert.Equal(t, expectedCreateFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
assert.Equal(t, expectedCreateFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
assert.Equal(t, expectedCreateFileResponse.Commit.Committer.Email, filesResponse.Commit.Committer.Email)
assert.Equal(t, expectedCreateFileResponse.Commit.Committer.Name, filesResponse.Commit.Committer.Name)
assert.Nil(t, filesResponse.Files[2]) // test delete file
})
}
// Test changing files in a new branch
changeFilesOptions := getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "new_branch"
fileID++
createTreePath := fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath := fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url := fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
req := NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp := MakeRequest(t, req, http.StatusCreated)
filesResponse := DecodeJSON(t, resp, &api.FilesResponse{})
expectedCreateSHA := "a635aa942442ddfdba07468cf9661c08fbdf0ebf"
expectedCreateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/new/file%d.txt", fileID)
expectedCreateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/new/file%d.txt", fileID)
expectedUpdateSHA := "08bd14b2e2852529157324de9c226b3364e76136"
expectedUpdateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/update/file%d.txt", fileID)
expectedUpdateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/update/file%d.txt", fileID)
assert.Equal(t, expectedCreateSHA, filesResponse.Files[0].SHA)
assert.Equal(t, expectedCreateHTMLURL, *filesResponse.Files[0].HTMLURL)
assert.Equal(t, expectedCreateDownloadURL, *filesResponse.Files[0].DownloadURL)
assert.Equal(t, expectedUpdateSHA, filesResponse.Files[1].SHA)
assert.Equal(t, expectedUpdateHTMLURL, *filesResponse.Files[1].HTMLURL)
assert.Equal(t, expectedUpdateDownloadURL, *filesResponse.Files[1].DownloadURL)
assert.Nil(t, filesResponse.Files[2])
assert.Equal(t, changeFilesOptions.Message+"\n", filesResponse.Commit.Message)
// Test fails creating a file in a branch that already exists without force
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "develop"
changeFilesOptions.ForcePush = false
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url = fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
assert.Contains(t, resp.Body.String(), `"message":"branch already exists [name: develop]"`)
// Test succeeds creating a file in a branch that already exists with force
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "develop"
changeFilesOptions.ForcePush = true
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url = fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusCreated)
filesResponse = DecodeJSON(t, resp, &api.FilesResponse{})
expectedCreateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/develop/new/file%d.txt", fileID)
expectedCreateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/develop/new/file%d.txt", fileID)
expectedUpdateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/develop/update/file%d.txt", fileID)
expectedUpdateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/develop/update/file%d.txt", fileID)
assert.Equal(t, expectedCreateSHA, filesResponse.Files[0].SHA)
assert.Equal(t, expectedCreateHTMLURL, *filesResponse.Files[0].HTMLURL)
assert.Equal(t, expectedCreateDownloadURL, *filesResponse.Files[0].DownloadURL)
assert.Equal(t, expectedUpdateSHA, filesResponse.Files[1].SHA)
assert.Equal(t, expectedUpdateHTMLURL, *filesResponse.Files[1].HTMLURL)
assert.Equal(t, expectedUpdateDownloadURL, *filesResponse.Files[1].DownloadURL)
assert.Nil(t, filesResponse.Files[2])
assert.Equal(t, changeFilesOptions.Message+"\n", filesResponse.Commit.Message)
// Test fails creating a file in a branch that already exists with force and branch protection enabled
protectionReq := NewRequestWithJSON(t, "POST", "/v1/repos/user2/repo1/branch_protections", &api.BranchProtection{
RuleName: "develop",
BranchName: "develop",
Priority: 1,
EnablePush: true,
EnableForcePush: false,
}).AddTokenAuth(token2)
MakeRequest(t, protectionReq, http.StatusCreated)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "develop"
changeFilesOptions.ForcePush = true
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url = fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusForbidden)
assert.Contains(t, resp.Body.String(), `"message":"branch develop is protected from force push"`)
// Test updating a file and renaming it
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
fileID++
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
createFile(user2, repo1, updateTreePath)
changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]}
changeFilesOptions.Files[0].FromPath = updateTreePath
changeFilesOptions.Files[0].Path = "rename/" + updateTreePath
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusCreated)
filesResponse = DecodeJSON(t, resp, &api.FilesResponse{})
expectedUpdateSHA = "08bd14b2e2852529157324de9c226b3364e76136"
expectedUpdateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/master/rename/update/file%d.txt", fileID)
expectedUpdateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/master/rename/update/file%d.txt", fileID)
assert.Equal(t, expectedUpdateSHA, filesResponse.Files[0].SHA)
assert.Equal(t, expectedUpdateHTMLURL, *filesResponse.Files[0].HTMLURL)
assert.Equal(t, expectedUpdateDownloadURL, *filesResponse.Files[0].DownloadURL)
// Test updating a file without a message
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Message = ""
changeFilesOptions.BranchName = repo1.DefaultBranch
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusCreated)
filesResponse = DecodeJSON(t, resp, &api.FilesResponse{})
expectedMessage := fmt.Sprintf("Add %v\nUpdate %v\nDelete %v\n", createTreePath, updateTreePath, deleteTreePath)
assert.Equal(t, expectedMessage, filesResponse.Commit.Message)
// Test updating a file with the wrong SHA
fileID++
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
createFile(user2, repo1, updateTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]}
changeFilesOptions.Files[0].Path = updateTreePath
correctSHA := changeFilesOptions.Files[0].SHA
changeFilesOptions.Files[0].SHA = "badsha"
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
expectedAPIError := &context.APIError{
Message: "sha does not match [given: " + changeFilesOptions.Files[0].SHA + ", expected: " + correctSHA + "]",
URL: setting.API.SwaggerURL,
}
apiError := DecodeJSON(t, resp, &context.APIError{})
assert.Equal(t, expectedAPIError, apiError)
// Test creating a file in repo1 by user4 who does not have write access
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo16, updateTreePath)
createFile(user2, repo16, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions).
AddTokenAuth(token4)
MakeRequest(t, req, http.StatusNotFound)
// Tests a repo with no token given so will fail
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo16, updateTreePath)
createFile(user2, repo16, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions)
MakeRequest(t, req, http.StatusNotFound)
// Test using access token for a private repo that the user of the token owns
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo16, updateTreePath)
createFile(user2, repo16, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions).
AddTokenAuth(token2)
MakeRequest(t, req, http.StatusCreated)
// Test using org repo "org3/repo3" where user2 is a collaborator
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(org3, repo3, updateTreePath)
createFile(org3, repo3, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/contents", org3.Name, repo3.Name), &changeFilesOptions).
AddTokenAuth(token2)
MakeRequest(t, req, http.StatusCreated)
// Test using org repo "org3/repo3" with no user token
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(org3, repo3, updateTreePath)
createFile(org3, repo3, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/contents", org3.Name, repo3.Name), &changeFilesOptions)
MakeRequest(t, req, http.StatusNotFound)
// Test using repo "user2/repo1" where user4 is a NOT collaborator
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/contents", user2.Name, repo1.Name), &changeFilesOptions).
AddTokenAuth(token4)
MakeRequest(t, req, http.StatusForbidden)
})
}