Files
forge/tests/integration/api_releases_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

465 lines
18 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
auth_model "github.com/hanzoai/git/models/auth"
"github.com/hanzoai/git/models/db"
access_model "github.com/hanzoai/git/models/perm/access"
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/modules/test"
"github.com/hanzoai/git/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIReleaseRead(t *testing.T) {
defer tests.PrepareTestEnv(t)()
t.Run("DraftReleaseAttachmentAccess", testAPIDraftReleaseAttachmentAccess)
t.Run("ListReleasesWithWriteToken", testAPIListReleasesWithWriteToken)
t.Run("ListReleasesWithReadToken", testAPIListReleasesWithReadToken)
t.Run("GetDraftRelease", testAPIGetDraftRelease)
t.Run("GetLatestRelease", testAPIGetLatestRelease)
t.Run("GetReleaseByTag", testAPIGetReleaseByTag)
t.Run("GetDraftReleaseByTag", testAPIGetDraftReleaseByTag)
t.Run("EditReleaseAttachmentWithUnallowedFile", testAPIEditReleaseAttachmentWithUnallowedFile) // failed attempt, so it is also a read test
}
func testAPIListReleasesWithWriteToken(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository)
link, _ := url.Parse(fmt.Sprintf("/v1/repos/%s/%s/releases", user2.Name, repo.Name))
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
apiReleases := DecodeJSON(t, resp, []*api.Release{})
if assert.Len(t, apiReleases, 3) {
for _, release := range apiReleases {
switch release.ID {
case 1:
assert.False(t, release.IsDraft)
assert.False(t, release.IsPrerelease)
assert.True(t, strings.HasSuffix(release.UploadURL, "/v1/repos/user2/repo1/releases/1/assets"), release.UploadURL)
case 4:
assert.True(t, release.IsDraft)
assert.False(t, release.IsPrerelease)
assert.True(t, strings.HasSuffix(release.UploadURL, "/v1/repos/user2/repo1/releases/4/assets"), release.UploadURL)
case 5:
assert.False(t, release.IsDraft)
assert.True(t, release.IsPrerelease)
assert.True(t, strings.HasSuffix(release.UploadURL, "/v1/repos/user2/repo1/releases/5/assets"), release.UploadURL)
default:
assert.NoError(t, fmt.Errorf("unexpected release: %v", release))
}
}
}
// test filter
testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) {
link.RawQuery = query.Encode()
req := NewRequest(t, "GET", link.String())
if auth {
req.AddTokenAuth(token)
}
resp = MakeRequest(t, req, http.StatusOK)
apiReleases = DecodeJSON(t, resp, []*api.Release{})
assert.Len(t, apiReleases, expectedLength, msgAndArgs)
}
testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts")
testFilterByLen(true, url.Values{"draft": {"true"}}, 1, "repo owner should see drafts")
testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts")
testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases")
testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release")
testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft")
}
func testAPIListReleasesWithReadToken(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeReadRepository)
link, _ := url.Parse(fmt.Sprintf("/v1/repos/%s/%s/releases", user2.Name, repo.Name))
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
apiReleases := DecodeJSON(t, resp, []*api.Release{})
if assert.Len(t, apiReleases, 2) {
for _, release := range apiReleases {
switch release.ID {
case 1:
assert.False(t, release.IsDraft)
assert.False(t, release.IsPrerelease)
assert.True(t, strings.HasSuffix(release.UploadURL, "/v1/repos/user2/repo1/releases/1/assets"), release.UploadURL)
case 5:
assert.False(t, release.IsDraft)
assert.True(t, release.IsPrerelease)
assert.True(t, strings.HasSuffix(release.UploadURL, "/v1/repos/user2/repo1/releases/5/assets"), release.UploadURL)
default:
assert.NoError(t, fmt.Errorf("unexpected release: %v", release))
}
}
}
// test filter
testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) {
link.RawQuery = query.Encode()
req := NewRequest(t, "GET", link.String())
if auth {
req.AddTokenAuth(token)
}
resp = MakeRequest(t, req, http.StatusOK)
apiReleases = DecodeJSON(t, resp, []*api.Release{})
assert.Len(t, apiReleases, expectedLength, msgAndArgs)
}
testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts")
testFilterByLen(true, url.Values{"draft": {"true"}}, 0, "repo owner with read token should not see drafts")
testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts")
testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases")
testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release")
testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft")
}
func testAPIGetDraftRelease(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 4})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
reader := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
urlStr := fmt.Sprintf("/v1/repos/%s/%s/releases/%d", owner.Name, repo.Name, release.ID)
MakeRequest(t, NewRequest(t, "GET", urlStr), http.StatusNotFound)
readerToken := getUserToken(t, reader.LowerName, auth_model.AccessTokenScopeReadRepository)
MakeRequest(t, NewRequest(t, "GET", urlStr).AddTokenAuth(readerToken), http.StatusNotFound)
ownerToken := getUserToken(t, owner.LowerName, auth_model.AccessTokenScopeWriteRepository)
resp := MakeRequest(t, NewRequest(t, "GET", urlStr).AddTokenAuth(ownerToken), http.StatusOK)
apiRelease := DecodeJSON(t, resp, &api.Release{})
assert.Equal(t, release.Title, apiRelease.Title)
}
func createNewReleaseUsingAPI(t *testing.T, token string, owner *user_model.User, repo *repo_model.Repository, name, target, title, desc string) *api.Release {
urlStr := fmt.Sprintf("/v1/repos/%s/%s/releases", owner.Name, repo.Name)
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{
TagName: name,
Title: title,
Note: desc,
IsDraft: false,
IsPrerelease: false,
Target: target,
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
newRelease := DecodeJSON(t, resp, &api.Release{})
rel := &repo_model.Release{
ID: newRelease.ID,
TagName: newRelease.TagName,
Title: newRelease.Title,
}
unittest.AssertExistsAndLoadBean(t, rel)
assert.Equal(t, newRelease.Note, rel.Note)
return newRelease
}
func TestAPICreateAndUpdateRelease(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
assert.NoError(t, err)
defer gitRepo.Close()
err = gitRepo.CreateTag("v0.0.1", "master")
assert.NoError(t, err)
target, err := gitRepo.GetTagCommitID("v0.0.1")
assert.NoError(t, err)
newRelease := createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", target, "v0.0.1", "test")
urlStr := fmt.Sprintf("/v1/repos/%s/%s/releases/%d", owner.Name, repo.Name, newRelease.ID)
req := NewRequest(t, "GET", urlStr).
AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
release := DecodeJSON(t, resp, &api.Release{})
assert.Equal(t, newRelease.TagName, release.TagName)
assert.Equal(t, newRelease.Title, release.Title)
assert.Equal(t, newRelease.Note, release.Note)
req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditReleaseOption{
TagName: release.TagName,
Title: release.Title,
Note: "updated",
IsDraft: &release.IsDraft,
IsPrerelease: &release.IsPrerelease,
Target: release.Target,
}).AddTokenAuth(token)
resp = MakeRequest(t, req, http.StatusOK)
newRelease = DecodeJSON(t, resp, &api.Release{})
rel := &repo_model.Release{
ID: newRelease.ID,
TagName: newRelease.TagName,
Title: newRelease.Title,
}
unittest.AssertExistsAndLoadBean(t, rel)
assert.Equal(t, rel.Note, newRelease.Note)
}
func TestAPICreateProtectedTagRelease(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
writer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
session := loginUser(t, writer.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
assert.NoError(t, err)
defer gitRepo.Close()
commit, err := gitRepo.GetBranchCommit("master")
assert.NoError(t, err)
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/v1/repos/%s/%s/releases", repo.OwnerName, repo.Name), &api.CreateReleaseOption{
TagName: "v0.0.1",
Title: "v0.0.1",
IsDraft: false,
IsPrerelease: false,
Target: commit.ID.String(),
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnprocessableEntity)
}
func TestAPICreateReleaseToDefaultBranch(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test")
}
func TestAPICreateReleaseToDefaultBranchOnExistingTag(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
assert.NoError(t, err)
defer gitRepo.Close()
err = gitRepo.CreateTag("v0.0.1", "master")
assert.NoError(t, err)
createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test")
}
func TestAPICreateReleaseGivenInvalidTarget(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
urlStr := fmt.Sprintf("/v1/repos/%s/%s/releases", owner.Name, repo.Name)
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{
TagName: "i-point-to-an-invalid-target",
Title: "Invalid Target",
Target: "invalid-target",
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
}
func testAPIGetLatestRelease(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
req := NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s/releases/latest", owner.Name, repo.Name))
resp := MakeRequest(t, req, http.StatusOK)
release := DecodeJSON(t, resp, &api.Release{})
assert.Equal(t, "testing-release", release.Title)
}
func testAPIGetReleaseByTag(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
tag := "v1.1"
req := NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag))
resp := MakeRequest(t, req, http.StatusOK)
release := DecodeJSON(t, resp, &api.Release{})
assert.Equal(t, "testing-release", release.Title)
nonexistingtag := "nonexistingtag"
req = NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, nonexistingtag))
resp = MakeRequest(t, req, http.StatusNotFound)
err := DecodeJSON(t, resp, &api.APIError{})
assert.NotEmpty(t, err.Message)
}
func testAPIGetDraftReleaseByTag(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
tag := "draft-release"
// anonymous should not be able to get draft release
req := NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag))
MakeRequest(t, req, http.StatusNotFound)
// user 40 should be able to get draft release because he has write access to the repository
token := getUserToken(t, "user40", auth_model.AccessTokenScopeReadRepository)
req = NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusOK)
release := DecodeJSON(t, resp, &api.Release{})
assert.Equal(t, "draft-release", release.Title)
// remove user 40 access from the repository
_, err := db.DeleteByID[access_model.Access](t.Context(), 30)
assert.NoError(t, err)
// user 40 should not be able to get draft release
req = NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
// user 2 should be able to get draft release because he is the publisher
user2Token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
req = NewRequest(t, "GET", fmt.Sprintf("/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag)).AddTokenAuth(user2Token)
resp = MakeRequest(t, req, http.StatusOK)
release = DecodeJSON(t, resp, &api.Release{})
assert.Equal(t, "draft-release", release.Title)
}
func TestAPIDeleteReleaseByTagName(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
// delete release
req := NewRequestf(t, http.MethodDelete, "/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name).
AddTokenAuth(token)
_ = MakeRequest(t, req, http.StatusNoContent)
// make sure release is deleted
req = NewRequestf(t, http.MethodDelete, "/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name).
AddTokenAuth(token)
_ = MakeRequest(t, req, http.StatusNotFound)
// delete release tag too
req = NewRequestf(t, http.MethodDelete, "/v1/repos/%s/%s/tags/release-tag", owner.Name, repo.Name).
AddTokenAuth(token)
_ = MakeRequest(t, req, http.StatusNoContent)
}
func TestAPIUploadAssetRelease(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Repository.Release.FileMaxSize, 1)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
session := loginUser(t, owner.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
bufImageBytes := testGeneratePngBytes()
bufLargeBytes := bytes.Repeat([]byte{' '}, 2*1024*1024)
release := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
assetURL := fmt.Sprintf("/v1/repos/%s/%s/releases/%d/assets", owner.Name, repo.Name, release.ID)
t.Run("multipart/form-data", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
const filename = "image.png"
performUpload := func(t *testing.T, uploadURL string, _ []byte, _ int) *httptest.ResponseRecorder {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("attachment", filename)
assert.NoError(t, err)
_, err = io.Copy(part, bytes.NewReader(bufImageBytes))
assert.NoError(t, err)
err = writer.Close()
assert.NoError(t, err)
req := NewRequestWithBody(t, http.MethodPost, uploadURL, bytes.NewReader(body.Bytes())).
AddTokenAuth(token).
SetHeader("Content-Type", writer.FormDataContentType())
return MakeRequest(t, req, http.StatusCreated)
}
performUpload(t, assetURL, bufLargeBytes, http.StatusRequestEntityTooLarge)
t.Run("UploadDefaultName", func(t *testing.T) {
resp := performUpload(t, assetURL, bufImageBytes, http.StatusCreated)
attachment := DecodeJSON(t, resp, &api.Attachment{})
assert.Equal(t, filename, attachment.Name)
assert.EqualValues(t, 104, attachment.Size)
})
t.Run("UploadWithName", func(t *testing.T) {
resp := performUpload(t, assetURL+"?name=test-asset", bufImageBytes, http.StatusCreated)
attachment := DecodeJSON(t, resp, &api.Attachment{})
assert.Equal(t, "test-asset", attachment.Name)
assert.EqualValues(t, 104, attachment.Size)
})
})
t.Run("application/octet-stream", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithBody(t, http.MethodPost, assetURL, bytes.NewReader(bufImageBytes)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=stream.bin", bytes.NewReader(bufLargeBytes)).AddTokenAuth(token)
MakeRequest(t, req, http.StatusRequestEntityTooLarge)
req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=stream.bin", bytes.NewReader(bufImageBytes)).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
attachment := DecodeJSON(t, resp, &api.Attachment{})
assert.Equal(t, "stream.bin", attachment.Name)
assert.EqualValues(t, 104, attachment.Size)
})
}