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

481 lines
15 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"bytes"
"net/http"
"path"
"strconv"
"strings"
"testing"
auth_model "github.com/hanzoai/git/models/auth"
git_model "github.com/hanzoai/git/models/git"
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/json"
"github.com/hanzoai/git/modules/lfs"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/test"
"github.com/hanzoai/git/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPILFSNotStarted(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.LFS.StartServer, false)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
req := NewRequestf(t, "POST", "/%s/%s.git/info/lfs/objects/batch", user.Name, repo.Name)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequestf(t, "PUT", "/%s/%s.git/info/lfs/objects/oid/10", user.Name, repo.Name)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/objects/oid/name", user.Name, repo.Name)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/objects/oid", user.Name, repo.Name)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/verify", user.Name, repo.Name)
MakeRequest(t, req, http.StatusNotFound)
}
func TestAPILFSMediaType(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
req := NewRequestf(t, "POST", "/%s/%s.git/info/lfs/objects/batch", user.Name, repo.Name)
MakeRequest(t, req, http.StatusUnsupportedMediaType)
req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/verify", user.Name, repo.Name)
MakeRequest(t, req, http.StatusUnsupportedMediaType)
}
func createLFSTestRepository(t *testing.T, repoName string) *repo_model.Repository {
ctx := NewAPITestContext(t, "user2", repoName, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
t.Run("CreateRepo", doAPICreateRepository(ctx, false))
repo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), "user2", repoName)
require.NoError(t, err)
return repo
}
func TestAPILFSBatch(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
repo := createLFSTestRepository(t, "lfs-batch-repo")
oid := storeObjectInRepo(t, repo.ID, "dummy1")
defer git_model.RemoveLFSMetaObjectByOid(t.Context(), repo.ID, oid)
session := loginUser(t, "user2")
newRequest := func(t testing.TB, br *lfs.BatchRequest) *RequestWrapper {
return NewRequestWithJSON(t, "POST", "/user2/lfs-batch-repo.git/info/lfs/objects/batch", br).
SetHeader("Accept", lfs.AcceptHeader).
SetHeader("Content-Type", lfs.MediaType)
}
decodeResponse := func(t *testing.T, b *bytes.Buffer) *lfs.BatchResponse {
var br lfs.BatchResponse
assert.NoError(t, json.Unmarshal(b.Bytes(), &br))
return &br
}
t.Run("InvalidJsonRequest", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, nil)
session.MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("InvalidOperation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "dummy",
})
session.MakeRequest(t, req, http.StatusBadRequest)
})
t.Run("InvalidPointer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "download",
Objects: []lfs.Pointer{
{Oid: "dummy"},
{Oid: oid, Size: -1},
},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 2)
assert.Equal(t, "dummy", br.Objects[0].Oid)
assert.Equal(t, oid, br.Objects[1].Oid)
assert.Equal(t, int64(0), br.Objects[0].Size)
assert.Equal(t, int64(-1), br.Objects[1].Size)
assert.NotNil(t, br.Objects[0].Error)
assert.NotNil(t, br.Objects[1].Error)
assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code)
assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[1].Error.Code)
assert.Equal(t, "Oid or size are invalid", br.Objects[0].Error.Message)
assert.Equal(t, "Oid or size are invalid", br.Objects[1].Error.Message)
})
t.Run("PointerSizeMismatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "download",
Objects: []lfs.Pointer{
{Oid: oid, Size: 1},
},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code)
assert.Equal(t, "Object "+oid+" is not 1 bytes", br.Objects[0].Error.Message)
})
t.Run("Download", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("PointerNotInStore", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "download",
Objects: []lfs.Pointer{
{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
assert.Equal(t, http.StatusNotFound, br.Objects[0].Error.Code)
})
t.Run("MetaNotFound", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
contentStore := lfs.NewContentStore()
exist, err := contentStore.Exists(p)
assert.NoError(t, err)
assert.False(t, exist)
err = contentStore.Put(p, bytes.NewReader([]byte("dummy0")))
assert.NoError(t, err)
req := newRequest(t, &lfs.BatchRequest{
Operation: "download",
Objects: []lfs.Pointer{p},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
assert.Equal(t, http.StatusNotFound, br.Objects[0].Error.Code)
})
t.Run("Success", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "download",
Objects: []lfs.Pointer{
{Oid: oid, Size: 6},
},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
assert.Contains(t, br.Objects[0].Actions, "download")
l := br.Objects[0].Actions["download"]
assert.NotNil(t, l)
assert.NotEmpty(t, l.Href)
})
})
t.Run("Upload", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
t.Run("FileTooBig", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
defer test.MockVariableValue(&setting.LFS.MaxFileSize, 2)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "upload",
Objects: []lfs.Pointer{
{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.NotNil(t, br.Objects[0].Error)
assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code)
assert.Equal(t, "Size must be less than or equal to 2", br.Objects[0].Error.Message)
})
t.Run("CrossRepoObjectRequiresUpload", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
// An object whose bytes already exist in the store but which is not
// linked to this repo must not be silently linked, even when the
// caller can access it in another repo. Auto-linking let a deploy key
// (whose token carries the repo owner's identity) exfiltrate objects
// across repos without proving possession. The client must upload.
p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
contentStore := lfs.NewContentStore()
exist, err := contentStore.Exists(p)
assert.NoError(t, err)
assert.True(t, exist)
// The object is linked to another repo owned by the same user.
repo2 := createLFSTestRepository(t, "lfs-batch2-repo")
storeObjectInRepo(t, repo2.ID, "dummy0")
meta, err := git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
assert.Nil(t, meta)
assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
req := newRequest(t, &lfs.BatchRequest{
Operation: "upload",
Objects: []lfs.Pointer{p},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
// The client is told to upload instead of the object being linked.
assert.Contains(t, br.Objects[0].Actions, "upload")
// No meta object may have been created for this repo.
meta, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
assert.Nil(t, meta)
assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
// Cleanup
err = contentStore.Delete(p.RelativePath())
assert.NoError(t, err)
})
t.Run("AlreadyExists", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "upload",
Objects: []lfs.Pointer{
{Oid: oid, Size: 6},
},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
assert.Empty(t, br.Objects[0].Actions)
})
t.Run("NewFile", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.BatchRequest{
Operation: "upload",
Objects: []lfs.Pointer{
{Oid: "d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0153", Size: 1},
},
})
resp := session.MakeRequest(t, req, http.StatusOK)
br := decodeResponse(t, resp.Body)
assert.Len(t, br.Objects, 1)
assert.Nil(t, br.Objects[0].Error)
assert.Contains(t, br.Objects[0].Actions, "upload")
ul := br.Objects[0].Actions["upload"]
assert.NotNil(t, ul)
assert.NotEmpty(t, ul.Href)
assert.Equal(t, "chunked", ul.Header["Transfer-Encoding"], "git-lfs client needs Transfer-Encoding to do chunked transfer")
assert.Contains(t, br.Objects[0].Actions, "verify")
vl := br.Objects[0].Actions["verify"]
assert.NotNil(t, vl)
assert.NotEmpty(t, vl.Href)
})
})
}
func TestAPILFSUpload(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
repo := createLFSTestRepository(t, "lfs-upload-repo")
oid := storeObjectInRepo(t, repo.ID, "dummy3")
defer git_model.RemoveLFSMetaObjectByOid(t.Context(), repo.ID, oid)
session := loginUser(t, "user2")
newRequest := func(t testing.TB, p lfs.Pointer, content string) *RequestWrapper {
return NewRequestWithBody(t, "PUT", path.Join("/user2/lfs-upload-repo.git/info/lfs/objects/", p.Oid, strconv.FormatInt(p.Size, 10)), strings.NewReader(content))
}
t.Run("InvalidPointer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: "dummy"}, "")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("AlreadyExistsInStore", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
p := lfs.Pointer{Oid: "83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4", Size: 6}
contentStore := lfs.NewContentStore()
exist, err := contentStore.Exists(p)
assert.NoError(t, err)
assert.False(t, exist)
err = contentStore.Put(p, bytes.NewReader([]byte("dummy5")))
assert.NoError(t, err)
meta, err := git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
assert.Nil(t, meta)
assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
t.Run("InvalidAccess", func(t *testing.T) {
req := newRequest(t, p, "invalid")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("ValidAccess", func(t *testing.T) {
req := newRequest(t, p, "dummy5")
session.MakeRequest(t, req, http.StatusOK)
meta, err = git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
assert.NoError(t, err)
assert.NotNil(t, meta)
})
// Cleanup
err = contentStore.Delete(p.RelativePath())
assert.NoError(t, err)
})
t.Run("MetaAlreadyExists", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: oid, Size: 6}, "")
session.MakeRequest(t, req, http.StatusOK)
})
t.Run("HashMismatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: "2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a", Size: 1}, "a")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("SizeMismatch", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, lfs.Pointer{Oid: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", Size: 2}, "a")
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("Success", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
p := lfs.Pointer{Oid: "71b41d6dd48dc58eba8f5cf9edf30fef6597fdf285a521bb8fcbad4b3d50887d", Size: 5}
req := newRequest(t, p, "forge")
session.MakeRequest(t, req, http.StatusOK)
contentStore := lfs.NewContentStore()
exist, err := contentStore.Exists(p)
assert.NoError(t, err)
assert.True(t, exist)
meta, err := git_model.GetLFSMetaObjectByOid(t.Context(), repo.ID, p.Oid)
assert.NoError(t, err)
assert.NotNil(t, meta)
})
}
func TestAPILFSVerify(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
repo := createLFSTestRepository(t, "lfs-verify-repo")
oid := storeObjectInRepo(t, repo.ID, "dummy3")
defer git_model.RemoveLFSMetaObjectByOid(t.Context(), repo.ID, oid)
session := loginUser(t, "user2")
newRequest := func(t testing.TB, p *lfs.Pointer) *RequestWrapper {
return NewRequestWithJSON(t, "POST", "/user2/lfs-verify-repo.git/info/lfs/verify", p).
SetHeader("Accept", lfs.AcceptHeader).
SetHeader("Content-Type", lfs.MediaType)
}
t.Run("InvalidJsonRequest", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, nil)
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("InvalidPointer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.Pointer{})
session.MakeRequest(t, req, http.StatusUnprocessableEntity)
})
t.Run("PointerNotExisting", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6})
session.MakeRequest(t, req, http.StatusNotFound)
})
t.Run("Success", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := newRequest(t, &lfs.Pointer{Oid: oid, Size: 6})
session.MakeRequest(t, req, http.StatusOK)
})
}