// Copyright 2019 The Gitea Authors. All rights reserved. // Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration import ( "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/migration" "forgejo.org/modules/optional" "forgejo.org/modules/process" "forgejo.org/modules/setting" "forgejo.org/modules/structs" app_context "forgejo.org/services/context" "forgejo.org/services/forms" "forgejo.org/services/migrations" 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" "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) 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.Init()` prev := setting.Migrations.AllowedDomains setting.Migrations.AllowedDomains = "localhost" migrations.Init() // reinitialize for changed allowList defer func() { setting.Migrations.AllowedDomains = prev migrations.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. sourceRepo, sourceRepoSha, cleanupSource := tests.CreateDeclarativeRepoWithOptions(t, user2, tests.DeclarativeRepoOptions{ IsPrivate: optional.Some(mc.privateSource), Files: optional.Some([]*files_service.ChangeRepoFile{ { Operation: "create", TreePath: "docs.md", ContentReader: strings.NewReader("hello, world"), }, }), }, ) defer cleanupSource() 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, _, cleanupMirror := tests.CreateDeclarativeRepoWithOptions(t, user2, tests.DeclarativeRepoOptions{}, ) defer cleanupMirror() // 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)) }) } 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 first commit message is present: if !strings.Contains(body, "Initial commit") { 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, } 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) bool { text := strings.TrimSpace(selection.Text()) assert.Contains(t, text, "mirror of") assert.Contains(t, text, sourceRepo.CloneLink().HTTPS) return true }) } 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")) } 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) }