mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
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]>
315 lines
13 KiB
Go
315 lines
13 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"image/png"
|
|
"io/fs"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
auth_model "github.com/hanzoai/git/models/auth"
|
|
repo_model "github.com/hanzoai/git/models/repo"
|
|
"github.com/hanzoai/git/modules/storage"
|
|
"github.com/hanzoai/git/modules/test"
|
|
"github.com/hanzoai/git/modules/web"
|
|
route_web "github.com/hanzoai/git/routers/web"
|
|
"github.com/hanzoai/git/services/context"
|
|
"github.com/hanzoai/git/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type attachmentScopeCase struct {
|
|
name string
|
|
url string
|
|
readIssueStatus int
|
|
readRepoStatus int
|
|
publicOnlyIssueStatus int
|
|
publicOnlyRepoStatus int
|
|
}
|
|
|
|
func testGeneratePngBytes() []byte {
|
|
myImage := image.NewRGBA(image.Rect(0, 0, 32, 32))
|
|
var buff bytes.Buffer
|
|
_ = png.Encode(&buff, myImage)
|
|
return buff.Bytes()
|
|
}
|
|
|
|
func testCreateIssueAttachment(t *testing.T, session *TestSession, repoURL, filename string, content []byte, expectedStatus int) string {
|
|
return testCreateAttachment(t, session, repoURL, "issues", filename, content, expectedStatus)
|
|
}
|
|
|
|
func testCreateReleaseAttachment(t *testing.T, session *TestSession, repoURL, filename string, content []byte, expectedStatus int) string {
|
|
return testCreateAttachment(t, session, repoURL, "releases", filename, content, expectedStatus)
|
|
}
|
|
|
|
func testCreateAttachment(t *testing.T, session *TestSession, repoURL, issueOrRelease, filename string, content []byte, expectedStatus int) string {
|
|
body := &bytes.Buffer{}
|
|
|
|
// Setup multi-part
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("file", filename)
|
|
assert.NoError(t, err)
|
|
_, err = part.Write(content)
|
|
assert.NoError(t, err)
|
|
err = writer.Close()
|
|
assert.NoError(t, err)
|
|
|
|
req := NewRequestWithBody(t, "POST", repoURL+"/"+issueOrRelease+"/attachments", body)
|
|
req.Header.Add("Content-Type", writer.FormDataContentType())
|
|
resp := session.MakeRequest(t, req, expectedStatus)
|
|
|
|
if expectedStatus != http.StatusOK {
|
|
return ""
|
|
}
|
|
obj := DecodeJSON(t, resp, map[string]string{})
|
|
return obj["uuid"]
|
|
}
|
|
|
|
func testDeleteIssueAttachment(t *testing.T, session *TestSession, repoURL, uuid string, expectedStatus int) {
|
|
req := NewRequestWithValues(t, "POST", repoURL+"/issues/attachments/remove", map[string]string{"file": uuid})
|
|
session.MakeRequest(t, req, expectedStatus)
|
|
}
|
|
|
|
func testDeleteReleaseAttachment(t *testing.T, session *TestSession, repoURL, uuid string, expectedStatus int) {
|
|
req := NewRequestWithValues(t, "POST", repoURL+"/releases/attachments/remove", map[string]string{"file": uuid})
|
|
session.MakeRequest(t, req, expectedStatus)
|
|
}
|
|
|
|
func TestAttachments(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
t.Run("CreateAnonymousAttachment", testCreateAnonymousAttachment)
|
|
t.Run("CreateUser2IssueAttachment", testCreateUser2IssueAttachment)
|
|
t.Run("UploadAttachmentDeleteTemp", testUploadAttachmentDeleteTemp)
|
|
t.Run("GetAttachment", testGetAttachment)
|
|
t.Run("DeleteAttachmentPermissions", testDeleteAttachmentPermissions)
|
|
}
|
|
|
|
func testUploadAttachmentDeleteTemp(t *testing.T) {
|
|
session := loginUser(t, "user2")
|
|
countTmpFile := func() int {
|
|
// TODO: GOLANG-HTTP-TMPDIR: Golang saves the uploaded file to os.TempDir() when it exceeds the max memory limit.
|
|
files, err := fs.Glob(os.DirFS(os.TempDir()), "multipart-*") //nolint:usetesting // Golang's "http" package's behavior
|
|
require.NoError(t, err)
|
|
return len(files)
|
|
}
|
|
var tmpFileCountDuringUpload int
|
|
defer test.MockVariableValue(&context.ParseMultipartFormMaxMemory, 1)()
|
|
defer web.RouteMock(route_web.RouterMockPointBeforeWebRoutes, func(resp http.ResponseWriter, req *http.Request) {
|
|
tmpFileCountDuringUpload = countTmpFile()
|
|
})()
|
|
_ = testCreateIssueAttachment(t, session, "/user2/repo1", "image.png", testGeneratePngBytes(), http.StatusOK)
|
|
assert.Equal(t, 1, tmpFileCountDuringUpload, "the temp file should exist when uploaded size exceeds the parse form's max memory")
|
|
assert.Equal(t, 0, countTmpFile(), "the temp file should be deleted after upload")
|
|
}
|
|
|
|
func testCreateAnonymousAttachment(t *testing.T) {
|
|
session := emptyTestSession(t)
|
|
testCreateIssueAttachment(t, session, "/user2/repo1", "image.png", testGeneratePngBytes(), http.StatusSeeOther)
|
|
}
|
|
|
|
func testCreateUser2IssueAttachment(t *testing.T) {
|
|
const repoURL = "/user2/repo1"
|
|
session := loginUser(t, "user2")
|
|
uuid := testCreateIssueAttachment(t, session, repoURL, "image.png", testGeneratePngBytes(), http.StatusOK)
|
|
|
|
req := NewRequest(t, "GET", repoURL+"/issues/new")
|
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
|
|
link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action")
|
|
assert.True(t, exists, "The template has changed")
|
|
|
|
postData := map[string]string{
|
|
"title": "New Issue With Attachment",
|
|
"content": "some content",
|
|
"files": uuid,
|
|
}
|
|
|
|
req = NewRequestWithValues(t, "POST", link, postData)
|
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
|
test.RedirectURL(resp) // check that redirect URL exists
|
|
|
|
// Validate that attachment is available
|
|
req = NewRequest(t, "GET", "/attachments/"+uuid)
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// anonymous visit should be allowed because user2/repo1 is a public repository
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func testGetAttachment(t *testing.T) {
|
|
adminSession := loginUser(t, "user1")
|
|
user2Session := loginUser(t, "user2")
|
|
user8Session := loginUser(t, "user8")
|
|
emptySession := emptyTestSession(t)
|
|
testCases := []struct {
|
|
name string
|
|
uuid string
|
|
createFile bool
|
|
session *TestSession
|
|
want int
|
|
}{
|
|
{"LinkedIssueUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, user2Session, http.StatusOK},
|
|
{"LinkedCommentUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a17", true, user2Session, http.StatusOK},
|
|
{"linked_release_uuid", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19", true, user2Session, http.StatusOK},
|
|
{"NotExistingUUID", "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusNotFound},
|
|
{"FileMissing", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusInternalServerError},
|
|
{"NotLinked", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user2Session, http.StatusNotFound},
|
|
{"NotLinkedAccessibleByUploader", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user8Session, http.StatusOK},
|
|
{"PublicByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, emptySession, http.StatusOK},
|
|
{"PrivateByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, emptySession, http.StatusNotFound},
|
|
{"PrivateAccessibleByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, adminSession, http.StatusOK},
|
|
{"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
|
|
{"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound},
|
|
{"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound},
|
|
// draft release attachments must only be reachable by users with write access, even on a public repo
|
|
{"DraftReleaseByOwner", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user2Session, http.StatusOK},
|
|
{"DraftReleaseByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, adminSession, http.StatusOK},
|
|
{"DraftReleaseByNonCollaborator", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, user8Session, http.StatusNotFound},
|
|
{"DraftReleaseByAnonymous", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a23", true, emptySession, http.StatusNotFound},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Write empty file to be available for response
|
|
if tc.createFile {
|
|
_, err := storage.Attachments.Save(repo_model.AttachmentRelativePath(tc.uuid), strings.NewReader("hello world"), -1)
|
|
assert.NoError(t, err)
|
|
}
|
|
// Actual test
|
|
req := NewRequest(t, "GET", "/attachments/"+tc.uuid)
|
|
tc.session.MakeRequest(t, req, tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testDeleteAttachmentPermissions(t *testing.T) {
|
|
const repoURL = "/user2/repo1"
|
|
|
|
ownerSession := loginUser(t, "user2")
|
|
readonlySession := loginUser(t, "user5")
|
|
|
|
issueFromOwner := testCreateIssueAttachment(t, ownerSession, repoURL, "owner-issue.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteIssueAttachment(t, readonlySession, repoURL, issueFromOwner, http.StatusForbidden)
|
|
|
|
issueFromReader := testCreateIssueAttachment(t, readonlySession, repoURL, "reader-issue.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteIssueAttachment(t, ownerSession, repoURL, issueFromReader, http.StatusOK)
|
|
|
|
testCreateReleaseAttachment(t, readonlySession, repoURL, "reader-release.png", testGeneratePngBytes(), http.StatusNotFound)
|
|
|
|
crossRepoUUID := testCreateIssueAttachment(t, ownerSession, repoURL, "cross-repo.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteIssueAttachment(t, ownerSession, "/user2/repo2", crossRepoUUID, http.StatusBadRequest)
|
|
testDeleteIssueAttachment(t, ownerSession, repoURL, crossRepoUUID, http.StatusOK)
|
|
|
|
releaseUUID := testCreateReleaseAttachment(t, ownerSession, repoURL, "reader-release.png", testGeneratePngBytes(), http.StatusOK)
|
|
testDeleteReleaseAttachment(t, ownerSession, repoURL, releaseUUID, http.StatusOK)
|
|
|
|
// test deleting release attachment from another repo
|
|
testDeleteReleaseAttachment(t, ownerSession, "/user2/repo2", crossRepoUUID, http.StatusBadRequest)
|
|
}
|
|
|
|
func TestAttachmentTokenScopes(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
for _, uuid := range []string{
|
|
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
|
|
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
|
|
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19",
|
|
"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
|
|
} {
|
|
_, err := storage.Attachments.Save(repo_model.AttachmentRelativePath(uuid), strings.NewReader("hello world"), -1)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
readIssueToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadIssue)
|
|
readRepoToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
|
|
miscToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadMisc)
|
|
publicOnlyIssueToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly)
|
|
publicOnlyRepoToken := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopePublicOnly)
|
|
|
|
cases := []attachmentScopeCase{
|
|
{
|
|
name: "GlobalPublicIssueAttachment",
|
|
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
|
|
readIssueStatus: http.StatusOK,
|
|
readRepoStatus: http.StatusForbidden,
|
|
publicOnlyIssueStatus: http.StatusOK,
|
|
publicOnlyRepoStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "RepoPublicIssueAttachment",
|
|
url: "/user2/repo1/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
|
|
readIssueStatus: http.StatusOK,
|
|
readRepoStatus: http.StatusForbidden,
|
|
publicOnlyIssueStatus: http.StatusOK,
|
|
publicOnlyRepoStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "GlobalPrivateIssueAttachment",
|
|
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
|
|
readIssueStatus: http.StatusOK,
|
|
readRepoStatus: http.StatusForbidden,
|
|
publicOnlyIssueStatus: http.StatusForbidden,
|
|
publicOnlyRepoStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "RepoPrivateIssueAttachment",
|
|
url: "/user2/repo2/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
|
|
readIssueStatus: http.StatusOK,
|
|
readRepoStatus: http.StatusForbidden,
|
|
publicOnlyIssueStatus: http.StatusForbidden,
|
|
publicOnlyRepoStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "GlobalPublicReleaseAttachment",
|
|
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19",
|
|
readIssueStatus: http.StatusForbidden,
|
|
readRepoStatus: http.StatusOK,
|
|
publicOnlyIssueStatus: http.StatusForbidden,
|
|
publicOnlyRepoStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "RepoPublicReleaseAttachment",
|
|
url: "/user2/repo1/releases/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19",
|
|
readIssueStatus: http.StatusForbidden,
|
|
readRepoStatus: http.StatusOK,
|
|
publicOnlyIssueStatus: http.StatusForbidden,
|
|
publicOnlyRepoStatus: http.StatusOK,
|
|
},
|
|
{
|
|
name: "GlobalPrivateReleaseAttachment",
|
|
url: "/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
|
|
readIssueStatus: http.StatusForbidden,
|
|
readRepoStatus: http.StatusOK,
|
|
publicOnlyIssueStatus: http.StatusForbidden,
|
|
publicOnlyRepoStatus: http.StatusForbidden,
|
|
},
|
|
{
|
|
name: "RepoPrivateReleaseAttachment",
|
|
url: "/user2/repo2/releases/attachments/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22",
|
|
readIssueStatus: http.StatusForbidden,
|
|
readRepoStatus: http.StatusOK,
|
|
publicOnlyIssueStatus: http.StatusForbidden,
|
|
publicOnlyRepoStatus: http.StatusForbidden,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(miscToken), http.StatusForbidden)
|
|
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(readIssueToken), tc.readIssueStatus)
|
|
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(readRepoToken), tc.readRepoStatus)
|
|
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(publicOnlyIssueToken), tc.publicOnlyIssueStatus)
|
|
MakeRequest(t, NewRequest(t, "GET", tc.url).AddTokenAuth(publicOnlyRepoToken), tc.publicOnlyRepoStatus)
|
|
})
|
|
}
|
|
}
|