gitforge/tests/integration/mirror_pull_test.go
Mathieu Fenniak 84ae61f2ce fix: compliance with [migrations].ALLOWED_DOMAINS, ...BLOCKED_DOMAINS, and ....ALLOW_LOCALNETWORKS for git & LFS ops (#13129)
System administrators can determine which hosts can be accessed by Forgejo for git mirroring via the config settings `[migrations].ALLOWED_DOMAINS`, `...BLOCKED_DOMAINS`, and `....ALLOW_LOCALNETWORKS`.  However, there were edge cases where these settings were ineffective:

- Config entries were only checked when mirrors were initially configured, and not later when a mirror pull/push occurred.  DNS changes, or server configuration changes, could cause a previously safe URL to no longer be safe.  These settings are now re-checked on every mirror pull/push.
- When accessing a git repository over http, git would follow HTTP redirects, allowing a violation of the `[migrations]` config entries.  The git option `http.followRedirects=false` is now set to prevent git from following these redirects.
- When performing Git LFS synchronization, it was assumed that the git URL passing migration URL checks was sufficient protection.  Specifically for pull mirrors which allow the usage of "Advanced settings" -> "LFS endpoint" to configure a separate LFS endpoint, these settings were never enforced, and for all mirrors they would have been subject to not being rechecked if the DNS had changed since configured.  LFS mirroring now uses an HTTP client that enforces the `[migrations]` config settings.

**Breaking:** If a repository mirror that depends on HTTP redirects will no longer work.

For example, if you've mirrored a repository from a Forgejo instance at `https://example.com/OWNER/REPO.git` and the repository or owner are renamed, Forgejo will provide an HTTP redirect that continues to allow git operations to work at the old URL.  Due to this redirect prohibition, this mirror will no longer work.

### Tests for Go changes

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
    - It is not currently documented that the `[migrations]` settings are used to control access to mirrors.  Forgejo advises it when an error occurs, but it could be added: https://codeberg.org/forgejo/docs/pulls/2023
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13129
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
2026-06-23 23:40:25 +02:00

761 lines
28 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"encoding/base64"
"fmt"
"net/http"
"net/url"
"os"
"path"
"strings"
"testing"
"time"
"forgejo.org/models/auth"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/lfs"
"forgejo.org/modules/log"
"forgejo.org/modules/migration"
"forgejo.org/modules/process"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"forgejo.org/modules/test"
app_context "forgejo.org/services/context"
"forgejo.org/services/forms"
migrations_allowlist "forgejo.org/services/migrations/allowlist"
mirror_service "forgejo.org/services/mirror"
release_service "forgejo.org/services/release"
repo_service "forgejo.org/services/repository"
files_service "forgejo.org/services/repository/files"
"forgejo.org/tests"
"forgejo.org/tests/forgery"
"github.com/PuerkitoBio/goquery"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMirrorPull(t *testing.T) {
t.Run("Basic", func(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
repoPath := repo_model.RepoPath(user.Name, repo.Name)
// This test is mirroring a local repo, and at one point passed just because it missed any "IsMigrateURLAllowed"
// check. As one is now rechecked during the migration, to preserve the rest of the logic of this test we enable
// local imports for user 2 during this test:
defer test.MockVariableValue(&setting.ImportLocalPaths, true)()
_, err := db.GetEngine(t.Context()).ID(user.ID).SetExpr("allow_import_local", true).Update(user)
require.NoError(t, err)
defer func() { // restore allow_import_local
_, err := db.GetEngine(t.Context()).ID(user.ID).SetExpr("allow_import_local", false).Update(user)
require.NoError(t, err)
}()
opts := migration.MigrateOptions{
RepoName: "test_mirror",
Description: "Test mirror",
Private: false,
Mirror: true,
CloneAddr: repoPath,
Wiki: true,
Releases: false,
}
mirrorRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
Name: opts.RepoName,
Description: opts.Description,
IsPrivate: opts.Private,
IsMirror: opts.Mirror,
Status: repo_model.RepositoryBeingMigrated,
})
require.NoError(t, err)
assert.True(t, mirrorRepo.IsMirror, "expected pull-mirror repo to be marked as a mirror immediately after its creation")
ctx := t.Context()
mirror, err := repo_service.MigrateRepositoryGitData(ctx, user, mirrorRepo, opts, nil)
require.NoError(t, err)
gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
require.NoError(t, err)
defer gitRepo.Close()
findOptions := repo_model.FindReleasesOptions{
IncludeDrafts: true,
IncludeTags: true,
RepoID: mirror.ID,
}
initCount, err := db.Count[repo_model.Release](db.DefaultContext, findOptions)
require.NoError(t, err)
require.NoError(t, release_service.CreateRelease(gitRepo, &repo_model.Release{
RepoID: repo.ID,
Repo: repo,
PublisherID: user.ID,
Publisher: user,
TagName: "v0.2",
Target: "master",
Title: "v0.2 is released",
Note: "v0.2 is released",
IsDraft: false,
IsPrerelease: false,
IsTag: true,
}, "", []*release_service.AttachmentChange{}))
_, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID)
require.NoError(t, err)
ok := mirror_service.SyncPullMirror(ctx, mirror.ID)
assert.True(t, ok)
count, err := db.Count[repo_model.Release](db.DefaultContext, findOptions)
require.NoError(t, err)
assert.Equal(t, initCount+1, count)
release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v0.2")
require.NoError(t, err)
require.NoError(t, release_service.DeleteReleaseByID(ctx, repo, release, user, true))
ok = mirror_service.SyncPullMirror(ctx, mirror.ID)
assert.True(t, ok)
count, err = db.Count[repo_model.Release](db.DefaultContext, findOptions)
require.NoError(t, err)
assert.Equal(t, initCount, count)
})
// How will we interact with the pull mirror during this test?
interactionMethod := []struct {
name string
useAPI bool
createPullMirror func(t *testing.T, sourceRepo *repo_model.Repository, authenticate bool) (repoName string)
verifyMirrorDetails func(t *testing.T, sourceRepo *repo_model.Repository, mirrorName string, authenticate bool)
triggerPullMirror func(t *testing.T, mirrorName string)
changePullMirrorCredentials func(t *testing.T, sourceRepo *repo_model.Repository, mirrorName string, authenticate bool)
changePullMirrorAddress func(t *testing.T, sourceRepo *repo_model.Repository, mirrorName string, authenticate bool)
}{
{
name: "API",
useAPI: true,
createPullMirror: createPullMirrorViaAPI,
triggerPullMirror: triggerPullMirrorViaAPI,
verifyMirrorDetails: func(t *testing.T, sourceRepo *repo_model.Repository, mirrorName string, authenticate bool) {
// API provides no visibility into a repo's mirror settings right now
},
},
{
name: "Web",
useAPI: false,
createPullMirror: createPullMirrorViaWeb,
triggerPullMirror: triggerPullMirrorViaWeb,
verifyMirrorDetails: verifyPullMirrorViaWeb,
changePullMirrorCredentials: changePullMirrorCredentialsViaWeb,
changePullMirrorAddress: changePullMirrorCredentialsViaWeb, // one endpoint, so same as creds
},
}
mirrorConfiguration := []struct {
name string
privateSource bool
}{
{
name: "HTTP Without Auth",
},
{
name: "HTTP With Auth",
privateSource: true,
},
}
// Not using MockVariableValue due to need to undo `migrations_allowlist.Init()`
prev := setting.Migrations.AllowedDomains
setting.Migrations.AllowedDomains = "localhost"
migrations_allowlist.Init() // reinitialize for changed allowList
defer func() {
setting.Migrations.AllowedDomains = prev
migrations_allowlist.Init() // reinitialize for changed allowList
}()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
for _, im := range interactionMethod {
for _, mc := range mirrorConfiguration {
t.Run(fmt.Sprintf("%s/%s", im.name, mc.name), func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// Create the source repository that will be mirrored.
var sourceRepoSha string
sourceRepo := forgery.CreateRepository(t, user2, &forgery.CreateRepositoryOptions{
IsPrivate: mc.privateSource,
Files: forgery.MapFS{
"docs.md": forgery.MapFile("hello, world"),
},
LatestSha: &sourceRepoSha,
})
require.NotEmpty(t, sourceRepoSha)
// Create pull mirror
mirror := im.createPullMirror(t, sourceRepo, mc.privateSource)
verifyPullMirrorContents(t, mirror, sourceRepoSha)
verifyPullMirrorConfig(t, mirror, sourceRepo, mc.privateSource)
im.verifyMirrorDetails(t, sourceRepo, mirror, mc.privateSource)
// Push a change to the source and refresh the mirror
sourceRepoSha = changePullMirrorSource(t, sourceRepo, sourceRepoSha)
im.triggerPullMirror(t, mirror)
waitForPullMirror(t, mirror, sourceRepoSha)
// Test changing the mirror's authentication method (if available)
if mc.privateSource && im.changePullMirrorCredentials != nil {
sourceRepoSha = changePullMirrorSource(t, sourceRepo, sourceRepoSha)
im.changePullMirrorCredentials(t, sourceRepo, mirror, mc.privateSource)
verifyPullMirrorConfig(t, mirror, sourceRepo, mc.privateSource)
im.verifyMirrorDetails(t, sourceRepo, mirror, mc.privateSource)
im.triggerPullMirror(t, mirror)
waitForPullMirror(t, mirror, sourceRepoSha)
}
// Test changing the mirror's address (if available)
if im.changePullMirrorAddress != nil {
sourceRepo = renamePullMirrorSourceRepo(t, sourceRepo)
sourceRepoSha = changePullMirrorSource(t, sourceRepo, sourceRepoSha)
im.changePullMirrorAddress(t, sourceRepo, mirror, mc.privateSource)
verifyPullMirrorConfig(t, mirror, sourceRepo, mc.privateSource)
im.verifyMirrorDetails(t, sourceRepo, mirror, mc.privateSource)
im.triggerPullMirror(t, mirror)
waitForPullMirror(t, mirror, sourceRepoSha)
}
})
}
}
})
t.Run("migrate from repo config credentials", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
mirrorRepo := forgery.CreateRepository(t, user2, nil)
// Write to the repo a config file that would have plausibly existed before EncryptedRemoteAddress was
// introduced:
repoPath := mirrorRepo.RepoPath()
err := os.WriteFile(path.Join(repoPath, "config"), []byte(`
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = https://user:password@example.com/org/repo.git
tagOpt = --no-tags
fetch = +refs/*:refs/*
mirror = true
fetch = +refs/tags/*:refs/tags/*
`), 0o644)
require.NoError(t, err)
// Create a Mirror record without an EncryptedRemoteAddress:
mirror := &repo_model.Mirror{
RepoID: mirrorRepo.ID,
Interval: 8 * time.Hour,
EnablePrune: true,
}
_, err = db.GetEngine(t.Context()).Insert(mirror)
require.NoError(t, err)
require.Nil(t, mirror.EncryptedRemoteAddress)
remoteURL, err := mirror_service.DecryptOrRecoverRemoteAddress(t.Context(), mirror)
require.NoError(t, err)
assert.Equal(t, "https://user:password@example.com/org/repo.git", remoteURL.URL.String())
// EncryptedRemoteAddress should now be populated from the recovery:
assert.NotNil(t, mirror.EncryptedRemoteAddress)
maybeDecryptedURL, err := mirror.DecryptRemoteAddress()
require.NoError(t, err)
has, decryptedURL := maybeDecryptedURL.Get()
require.True(t, has)
assert.Equal(t, "https://user:password@example.com/org/repo.git", decryptedURL)
// SanitizedRemoteAddress can be fetched:
maybeSanitizedURL, err := mirror.SanitizedRemoteAddress()
require.NoError(t, err)
has, sanitizedURL := maybeSanitizedURL.Get()
require.True(t, has)
assert.Equal(t, "https://user@example.com/org/repo.git", sanitizedURL)
// Database record is updated in the database:
refetchMirror := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID})
assert.Equal(t, mirror.EncryptedRemoteAddress, refetchMirror.EncryptedRemoteAddress)
// Config file is rewritten:
config, err := os.ReadFile(path.Join(repoPath, "config"))
require.NoError(t, err)
assert.Equal(t, `
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = https://user@example.com/org/repo.git
tagOpt = --no-tags
fetch = +refs/*:refs/*
mirror = true
fetch = +refs/tags/*:refs/tags/*
`, string(config))
t.Run("modernize", func(t *testing.T) {
require.NoError(t, mirror_service.ModernizePullMirrorConfig(t.Context(), mirror))
config, err = os.ReadFile(path.Join(repoPath, "config"))
require.NoError(t, err)
assert.Equal(t, `
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = https://user@example.com/org/repo.git
tagOpt = --no-tags
fetch = +refs/*:refs/*
mirror = true
fetch = +refs/tags/*:refs/tags/*
[http]
followRedirects = false
`, string(config))
})
})
}
// Verifies that a pull mirror which was created while the remote address was permitted will fail to sync if the
// AllowedDomains configuration later changes such that the remote URL is no longer permitted.
func TestMirrorPullAddressCheck(t *testing.T) {
// Allow localhost as a migration domain so the mirror can initially be created from the local test server. Not
// using MockVariableValue due to need to undo `migrations_allowlist.Init()`.
prev := setting.Migrations.AllowedDomains
setting.Migrations.AllowedDomains = "localhost"
migrations_allowlist.Init() // reinitialize for changed allowList
defer func() {
setting.Migrations.AllowedDomains = prev
migrations_allowlist.Init() // reinitialize for changed allowList
}()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
defer tests.PrintCurrentTest(t)()
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
// Create the source repository that will be mirrored.
var sourceRepoSha string
sourceRepo := forgery.CreateRepository(t, user2, &forgery.CreateRepositoryOptions{
Files: forgery.MapFS{
"docs.md": forgery.MapFile("hello, world"),
},
LatestSha: &sourceRepoSha,
})
require.NotEmpty(t, sourceRepoSha)
// Create the pull mirror while localhost is still an allowed migration domain.
mirrorName := createPullMirrorViaWeb(t, sourceRepo, false)
verifyPullMirrorContents(t, mirrorName, sourceRepoSha)
// Reset the allowed domains to the default, which does not permit localhost.
setting.Migrations.AllowedDomains = prev
migrations_allowlist.Init()
// Re-triggering the mirror should now fail because the remote URL is no longer permitted.
mirrorRepo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), "user2", mirrorName)
require.NoError(t, err)
ok := mirror_service.SyncPullMirror(t.Context(), mirrorRepo.ID)
assert.False(t, ok, "expected pull mirror sync to fail because the remote URL is no longer permitted")
})
}
func createPullMirrorViaWeb(t *testing.T, sourceRepo *repo_model.Repository, authenticate bool) string {
session := loginUser(t, "user2")
mirrorName := fmt.Sprintf("pullmirror-%s", sourceRepo.Name)
form := &forms.MigrateRepoForm{
CloneAddr: sourceRepo.CloneLink().HTTPS,
Service: structs.PlainGitService,
UID: 2,
RepoName: mirrorName,
Mirror: true,
}
if authenticate {
form.AuthUsername = "user2"
form.AuthPassword = getTokenForLoggedInUser(t, session, auth.AccessTokenScopeReadRepository)
}
resp := session.MakeRequest(t,
NewRequestWithJSON(t, "POST", "/repo/migrate", form),
http.StatusSeeOther)
location := resp.Header().Get("Location")
assert.Equal(t, fmt.Sprintf("/user2/pullmirror-%s", sourceRepo.Name), location)
var lastBody string
if !assert.Eventuallyf(t,
func() bool {
resp := session.MakeRequest(t,
NewRequest(t, "GET", location),
http.StatusOK)
body := resp.Body.String()
lastBody = body
// Looking for the repo page to be fully populated indicating that the migration is complete:
// Check that the committed file is present:
if !strings.Contains(body, "docs.md") {
return false
}
// Check that the fork button is present:
if !strings.Contains(body, fmt.Sprintf("/user2/%s/fork", mirrorName)) {
return false
}
return true
},
15*time.Second, 1*time.Second,
"expected migration to complete and repo page to render") {
t.Logf("last received page body: %s", lastBody)
}
return mirrorName
}
func createPullMirrorViaAPI(t *testing.T, sourceRepo *repo_model.Repository, authenticate bool) string {
session := loginUser(t, "user2")
apiToken := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository)
mirrorName := fmt.Sprintf("pullmirror-%s", sourceRepo.Name)
form := &structs.MigrateRepoOptions{
CloneAddr: sourceRepo.CloneLink().HTTPS,
Service: "git",
RepoOwner: "user2",
RepoName: mirrorName,
Mirror: true,
LFS: true,
}
if authenticate {
form.AuthUsername = "user2"
form.AuthPassword = getTokenForLoggedInUser(t, session, auth.AccessTokenScopeReadRepository)
}
resp := session.MakeRequest(t,
NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", form).AddTokenAuth(apiToken),
http.StatusCreated)
var repo structs.Repository
DecodeJSON(t, resp, &repo)
assert.NotNil(t, repo)
assert.True(t, repo.Mirror)
assert.False(t, repo.Empty)
return mirrorName
}
func verifyPullMirrorViaWeb(t *testing.T, sourceRepo *repo_model.Repository, mirrorName string, authenticate bool) {
session := loginUser(t, "user2")
resp := session.MakeRequest(t,
NewRequestf(t, "GET", "/user2/%s/settings", mirrorName),
http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
htmlDoc.AssertAttrEqual(t, "#mirror_address", "value", sourceRepo.CloneLink().HTTPS)
if authenticate {
htmlDoc.AssertAttrEqual(t, "#mirror_username", "value", "user2")
htmlDoc.AssertAttrEqual(t, "#mirror_password", "value", "")
htmlDoc.AssertAttrEqual(t, "#mirror_password", "placeholder", "(Unchanged)")
} else {
htmlDoc.AssertAttrEqual(t, "#mirror_username", "value", "")
htmlDoc.AssertAttrEqual(t, "#mirror_password", "value", "")
htmlDoc.AssertAttrEqual(t, "#mirror_password", "placeholder", "(Unset)")
}
resp = session.MakeRequest(t,
NewRequestf(t, "GET", "/user2/%s", mirrorName),
http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
htmlDoc.AssertElementPredicate(t, ".fork-flag", func(selection *goquery.Selection) {
text := strings.TrimSpace(selection.Text())
assert.Contains(t, text, "mirror of")
assert.Contains(t, text, sourceRepo.CloneLink().HTTPS)
})
}
func triggerPullMirrorViaWeb(t *testing.T, mirrorName string) {
session := loginUser(t, "user2")
resp := session.MakeRequest(t,
NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/%s/settings", mirrorName), map[string]string{"action": "mirror-sync"}),
http.StatusSeeOther)
location := resp.Header().Get("Location")
assert.Equal(t, fmt.Sprintf("/user2/%s/settings", mirrorName), location)
}
func triggerPullMirrorViaAPI(t *testing.T, mirrorName string) {
session := loginUser(t, "user2")
apiToken := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository)
// Trigger sync...
session.MakeRequest(t,
NewRequestf(t, "POST", "/api/v1/repos/user2/%s/mirror-sync", mirrorName).AddTokenAuth(apiToken),
http.StatusOK)
}
func changePullMirrorCredentialsViaWeb(t *testing.T, sourceRepo *repo_model.Repository, mirrorName string, authenticate bool) {
session := loginUser(t, "user2")
form := map[string]string{
"action": "mirror",
"enable_prune": "on",
"interval": "8h0m0s",
"mirror_address": sourceRepo.CloneLink().HTTPS,
}
if authenticate {
form["mirror_username"] = "user2"
form["mirror_password"] = getTokenForLoggedInUser(t, session, auth.AccessTokenScopeReadRepository)
}
resp := session.MakeRequest(t,
NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/%s/settings", mirrorName), form),
http.StatusSeeOther)
location := resp.Header().Get("Location")
assert.Equal(t, fmt.Sprintf("/user2/%s/settings", mirrorName), location)
}
func verifyPullMirrorContents(t *testing.T, mirrorName, expectedSha string) {
session := loginUser(t, "user2")
apiToken := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeReadRepository)
resp := session.MakeRequest(t,
NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/%s/commits?sha=main&limit=1", mirrorName)).AddTokenAuth(apiToken),
http.StatusOK)
var commits []*structs.Commit
DecodeJSON(t, resp, &commits)
require.Len(t, commits, 1)
assert.Equal(t, expectedSha, commits[0].SHA)
}
func waitForPullMirror(t *testing.T, mirrorName, expectedSha string) {
session := loginUser(t, "user2")
apiToken := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeReadRepository)
var commits []*structs.Commit
if !assert.Eventually(t,
func() bool {
resp := session.MakeRequest(t,
NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/%s/commits?sha=main&limit=1", mirrorName)).AddTokenAuth(apiToken),
http.StatusOK)
DecodeJSON(t, resp, &commits)
require.Len(t, commits, 1)
return commits[0].SHA == expectedSha
},
15*time.Second, 1*time.Second) {
t.Logf("sync was supposed to bring repo to commit %s, but observed commits = %#v", expectedSha, commits)
}
}
func getGitConfig(t *testing.T, configFile, configPath string) string {
stdout, stderr, err := process.GetManager().Exec("getGitConfig", "git", "config", "--get", "--file", configFile, configPath)
require.NoError(t, err, "fetch config %s failed: git stderr: %s", configPath, stderr)
return strings.TrimSpace(stdout)
}
func verifyPullMirrorConfig(t *testing.T, mirrorName string, sourceRepo *repo_model.Repository, authenticate bool) {
mirrorRepo, err := repo_model.GetRepositoryByOwnerAndName(t.Context(), "user2", mirrorName)
require.NoError(t, err)
repoPath := mirrorRepo.RepoPath()
configPath := path.Join(repoPath, "config")
expectedURL := sourceRepo.CloneLink().HTTPS
if authenticate {
expectedURL = strings.Replace(expectedURL, "http://", "http://user2@", 1)
}
assert.Equal(t, expectedURL, getGitConfig(t, configPath, "remote.origin.url"))
assert.Equal(t, "true", getGitConfig(t, configPath, "remote.origin.mirror"))
assert.Equal(t, "+refs/tags/*:refs/tags/*", getGitConfig(t, configPath, "remote.origin.fetch"))
assert.Equal(t, "false", getGitConfig(t, configPath, "http.followRedirects"))
}
func changePullMirrorSource(t *testing.T, sourceRepo *repo_model.Repository, sourceRepoSha string) string {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
resp, err := files_service.ChangeRepoFiles(git.DefaultContext, sourceRepo, user2,
&files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "update",
TreePath: "docs.md",
ContentReader: strings.NewReader(uuid.NewString()),
},
},
Message: "add files",
OldBranch: "main",
NewBranch: "main",
Author: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Committer: &files_service.IdentityOptions{
Name: user2.Name,
Email: user2.Email,
},
Dates: &files_service.CommitDateOptions{
Author: time.Now(),
Committer: time.Now(),
},
LastCommitID: sourceRepoSha,
})
require.NoError(t, err)
assert.NotEmpty(t, resp)
return resp.Commit.SHA
}
func renamePullMirrorSourceRepo(t *testing.T, sourceRepo *repo_model.Repository) *repo_model.Repository {
session := loginUser(t, "user2")
apiToken := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository)
newName := uuid.NewString()
session.MakeRequest(t,
NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/user2/%s", sourceRepo.Name),
&structs.EditRepoOption{
Name: &newName,
}).AddTokenAuth(apiToken),
http.StatusOK)
newRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: sourceRepo.ID})
assert.Equal(t, newRepo.Name, newName)
assert.NotEqual(t, newRepo.CloneLink().HTTPS, sourceRepo.CloneLink().HTTPS) // should have changed to new name
return newRepo
}
func TestPullMirrorRedactCredentials(t *testing.T) {
defer unittest.OverrideFixtures("tests/integration/fixtures/TestPullMirrorRedactCredentials")()
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user2")
session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user2/repo1001/settings", map[string]string{
"action": "mirror-sync",
}), http.StatusSeeOther)
flashCookie := session.GetCookie(app_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.Equal(t, "info%3DPulling%2Bchanges%2Bfrom%2Bthe%2Bremote%2Bhttps%253A%252F%252Fexample.com%252Fexample%252Fexample.git%2Bat%2Bthe%2Bmoment.", flashCookie.Value)
}
func TestMirrorPullLFS(t *testing.T) {
// Not using MockVariableValue due to need to undo `migrations_allowlist.Init()`
prev := setting.Migrations.AllowedDomains
setting.Migrations.AllowedDomains = "localhost"
migrations_allowlist.Init() // reinitialize for changed allowList
defer func() {
setting.Migrations.AllowedDomains = prev
migrations_allowlist.Init() // reinitialize for changed allowList
}()
defer test.MockVariableValue(&setting.LFS.StartServer, true)()
onApplicationRun(t, func(t *testing.T, u *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.LoginName)
apiToken := getTokenForLoggedInUser(t, session, auth.AccessTokenScopeWriteRepository)
// Create the source repository that will be mirrored.
var sourceRepoSha string
sourceRepo := forgery.CreateRepository(t, user2, &forgery.CreateRepositoryOptions{
Files: forgery.MapFS{
"docs.md": forgery.MapFile("hello, world"),
".gitattributes": forgery.MapFile("*.txt filter=lfs diff=lfs merge=lfs -text"),
},
LatestSha: &sourceRepoSha,
})
require.NotEmpty(t, sourceRepoSha)
// Push a ".txt" file, which the .gitattributes should cause to be treated as an LFS file.
req := NewRequestWithJSON(t,
"POST",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/my-lfs-file.txt", sourceRepo.OwnerName, sourceRepo.Name),
&structs.CreateFileOptions{
FileOptions: structs.FileOptions{
BranchName: sourceRepo.DefaultBranch,
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("Hello!")),
}).AddTokenAuth(apiToken)
MakeRequest(t, req, http.StatusCreated)
// Create pull mirror
mirror := createPullMirrorViaAPI(t, sourceRepo, false)
// raw file will be an LFS pointer
resp := session.MakeRequest(t,
NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/%s/raw/%s/my-lfs-file.txt", mirror, sourceRepo.DefaultBranch)).AddTokenAuth(apiToken),
http.StatusOK)
assert.True(t, strings.HasPrefix(resp.Body.String(), "version https://git-lfs.github.com/spec/v1"), "my-lfs-file.txt should be stored as an LFS pointer")
// /media file will be the original correct contents
resp = session.MakeRequest(t,
NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/%s/media/%s/my-lfs-file.txt", mirror, sourceRepo.DefaultBranch)).AddTokenAuth(apiToken),
http.StatusOK)
assert.Equal(t, "Hello!", resp.Body.String())
t.Run("verify http.Transport secure", func(t *testing.T) {
prevBlocked := setting.Migrations.BlockedDomains
setting.Migrations.BlockedDomains = "example.com"
migrations_allowlist.Init() // reinitialize for changed allowList
defer func() {
setting.Migrations.BlockedDomains = prevBlocked
migrations_allowlist.Init() // reinitialize for changed allowList
}()
// Push a new LFS object to the source repo so that it is found during the next sync:
req := NewRequestWithJSON(t,
"POST",
fmt.Sprintf("/api/v1/repos/%s/%s/contents/my-second-lfs-file.txt", sourceRepo.OwnerName, sourceRepo.Name),
&structs.CreateFileOptions{
FileOptions: structs.FileOptions{
BranchName: sourceRepo.DefaultBranch,
},
ContentBase64: base64.StdEncoding.EncodeToString([]byte("Hello, this is a new file!")),
}).AddTokenAuth(apiToken)
MakeRequest(t, req, http.StatusCreated)
resp := session.MakeRequest(t,
NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/%s/raw/%s/my-second-lfs-file.txt", sourceRepo.Name, sourceRepo.DefaultBranch)).AddTokenAuth(apiToken),
http.StatusOK)
// Awkwardly this test won't try to download the LFS object because we're syncing between two local
// repositories and the LFS content store will be shared between them, so the content pointer is already
// present. To ensure the LFS client is triggered, delete the content... of course it won't be available to
// be synced then, but we're expecting an error in this test case.
pointer, err := lfs.ReadPointerFromBuffer(resp.Body.Bytes())
require.NoError(t, err)
contentStore := lfs.NewContentStore()
contentStore.Delete(pointer.RelativePath())
// In order to pass the migration URL check, but fail on the LFS endpoint access, we'll reconfigure the LFS
// endpoint to a different domain name which is prohibited:
mirrorRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: mirror})
mirrorObj := unittest.AssertExistsAndLoadBean(t, &repo_model.Mirror{RepoID: mirrorRepo.ID})
mirrorObj.LFSEndpoint = "https://example.com/something"
_, err = db.GetEngine(t.Context()).ID(mirrorObj.ID).Update(mirrorObj)
require.NoError(t, err)
lc, cleanup := test.NewLogChecker(log.DEFAULT, log.ERROR)
lc.Filter("migration can only call allowed HTTP servers (check your migrations.ALLOWED_DOMAINS/ALLOW_LOCALNETWORKS setting)")
lc.StopMark("SyncMirrors")
defer cleanup()
ok := mirror_service.SyncPullMirror(t.Context(), mirrorRepo.ID)
assert.True(t, ok) // LFS failure doesn't output a migration failure
logFiltered, _ := lc.Check(5 * time.Second)
assert.True(t, logFiltered[0], "expected migration error output")
})
})
}