diff --git a/.deadcode-out b/.deadcode-out index 51429164ae..ac62e77ba7 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -136,7 +136,6 @@ package "code.gitea.io/gitea/models/user" func DeleteUserSetting func GetUserEmailsByNames func GetUserNamesByIDs - func DeleteFederatedUser package "code.gitea.io/gitea/modules/activitypub" func (*Client).Post diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 85229994b4..78c13f33a0 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -72,6 +72,8 @@ var migrations = []*Migration{ NewMigration("Create the `federated_user` table", CreateFederatedUserTable), // v17 -> v18 NewMigration("Add `normalized_federated_uri` column to `user` table", AddNormalizedFederatedURIToUser), + // v18 -> v19 + NewMigration("Create the `following_repo` table", CreateFollowingRepoTable), } // GetCurrentDBVersion returns the current Forgejo database version. diff --git a/models/forgejo_migrations/v18.go b/models/forgejo_migrations/v18.go new file mode 100644 index 0000000000..afccfbfe15 --- /dev/null +++ b/models/forgejo_migrations/v18.go @@ -0,0 +1,18 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgejo_migrations //nolint:revive + +import "xorm.io/xorm" + +type FollowingRepo struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + ExternalID string `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + FederationHostID int64 `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + URI string +} + +func CreateFollowingRepoTable(x *xorm.Engine) error { + return x.Sync(new(FederatedUser)) +} diff --git a/models/repo/following_repo.go b/models/repo/following_repo.go new file mode 100644 index 0000000000..85b96aa147 --- /dev/null +++ b/models/repo/following_repo.go @@ -0,0 +1,39 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "code.gitea.io/gitea/modules/validation" +) + +// FollowingRepo represents a federated Repository Actor connected with a local Repo +type FollowingRepo struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + ExternalID string `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + FederationHostID int64 `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + URI string +} + +func NewFollowingRepo(repoID int64, externalID string, federationHostID int64, uri string) (FollowingRepo, error) { + result := FollowingRepo{ + RepoID: repoID, + ExternalID: externalID, + FederationHostID: federationHostID, + URI: uri, + } + if valid, err := validation.IsValid(result); !valid { + return FollowingRepo{}, err + } + return result, nil +} + +func (user FollowingRepo) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(user.RepoID, "UserID")...) + result = append(result, validation.ValidateNotEmpty(user.ExternalID, "ExternalID")...) + result = append(result, validation.ValidateNotEmpty(user.FederationHostID, "FederationHostID")...) + result = append(result, validation.ValidateNotEmpty(user.URI, "Uri")...) + return result +} diff --git a/models/repo/following_repo_test.go b/models/repo/following_repo_test.go new file mode 100644 index 0000000000..d0dd0a31a7 --- /dev/null +++ b/models/repo/following_repo_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/modules/validation" +) + +func Test_FollowingRepoValidation(t *testing.T) { + sut := FollowingRepo{ + RepoID: 12, + ExternalID: "12", + FederationHostID: 1, + URI: "http://localhost:3000/api/v1/activitypub/repo-id/1", + } + if res, err := validation.IsValid(sut); !res { + t.Errorf("sut should be valid but was %q", err) + } + + sut = FollowingRepo{ + ExternalID: "12", + FederationHostID: 1, + URI: "http://localhost:3000/api/v1/activitypub/repo-id/1", + } + if res, _ := validation.IsValid(sut); res { + t.Errorf("sut should be invalid") + } +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 28471159d8..6db7c30513 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -1,4 +1,5 @@ // Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo @@ -342,6 +343,11 @@ func (repo *Repository) APIURL() string { return setting.AppURL + "api/v1/repos/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name) } +// APActorID returns the activitypub repository API URL +func (repo *Repository) APActorID() string { + return fmt.Sprintf("%vapi/v1/activitypub/repository-id/%v", setting.AppURL, url.PathEscape(fmt.Sprint(repo.ID))) +} + // GetCommitsCountCacheKey returns cache key used for commits count caching. func (repo *Repository) GetCommitsCountCacheKey(contextName string, isRef bool) string { var prefix string diff --git a/models/repo/repo_repository.go b/models/repo/repo_repository.go new file mode 100644 index 0000000000..6780165a38 --- /dev/null +++ b/models/repo/repo_repository.go @@ -0,0 +1,60 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package repo + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/validation" +) + +func init() { + db.RegisterModel(new(FollowingRepo)) +} + +func FindFollowingReposByRepoID(ctx context.Context, repoID int64) ([]*FollowingRepo, error) { + maxFollowingRepos := 10 + sess := db.GetEngine(ctx).Where("repo_id=?", repoID) + sess = sess.Limit(maxFollowingRepos, 0) + followingRepoList := make([]*FollowingRepo, 0, maxFollowingRepos) + err := sess.Find(&followingRepoList) + if err != nil { + return make([]*FollowingRepo, 0, maxFollowingRepos), err + } + for _, followingRepo := range followingRepoList { + if res, err := validation.IsValid(*followingRepo); !res { + return make([]*FollowingRepo, 0, maxFollowingRepos), err + } + } + return followingRepoList, nil +} + +func StoreFollowingRepos(ctx context.Context, localRepoID int64, followingRepoList []*FollowingRepo) error { + for _, followingRepo := range followingRepoList { + if res, err := validation.IsValid(*followingRepo); !res { + return err + } + } + + // Begin transaction + ctx, committer, err := db.TxContext((ctx)) + if err != nil { + return err + } + defer committer.Close() + + _, err = db.GetEngine(ctx).Where("repo_id=?", localRepoID).Delete(FollowingRepo{}) + if err != nil { + return err + } + for _, followingRepo := range followingRepoList { + _, err = db.GetEngine(ctx).Insert(followingRepo) + if err != nil { + return err + } + } + + // Commit transaction + return committer.Commit() +} diff --git a/models/repo/repo_test.go b/models/repo/repo_test.go index 1a870224bf..a279478177 100644 --- a/models/repo/repo_test.go +++ b/models/repo/repo_test.go @@ -1,4 +1,5 @@ // Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo_test @@ -217,3 +218,12 @@ func TestComposeSSHCloneURL(t *testing.T) { setting.SSH.Port = 123 assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) } + +func TestAPActorID(t *testing.T) { + repo := repo_model.Repository{ID: 1} + url := repo.APActorID() + expected := "https://try.gitea.io/api/v1/activitypub/repository-id/1" + if url != expected { + t.Errorf("unexpected APActorID, expected: %q, actual: %q", expected, url) + } +} diff --git a/modules/forgefed/actor.go b/modules/forgefed/actor.go index d3cae20dec..0ef46185d1 100644 --- a/modules/forgefed/actor.go +++ b/modules/forgefed/actor.go @@ -8,7 +8,6 @@ import ( "net/url" "strings" - "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/validation" ap "github.com/go-ap/activitypub" @@ -71,10 +70,6 @@ type PersonID struct { // Factory function for PersonID. Created struct is asserted to be valid func NewPersonID(uri, source string) (PersonID, error) { - // TODO: remove after test - //if !validation.IsValidExternalURL(uri) { - // return PersonId{}, fmt.Errorf("uri %s is not a valid external url", uri) - //} result, err := newActorID(uri) if err != nil { return PersonID{}, err @@ -126,16 +121,13 @@ type RepositoryID struct { // Factory function for RepositoryID. Created struct is asserted to be valid. func NewRepositoryID(uri, source string) (RepositoryID, error) { - if !validation.IsAPIURL(uri) { - return RepositoryID{}, fmt.Errorf("uri %s is not a valid repo url on this host %s", uri, setting.AppURL+"api") - } result, err := newActorID(uri) if err != nil { return RepositoryID{}, err } result.Source = source - // validate Person specific path + // validate Person specific repoID := RepositoryID{result} if valid, err := validation.IsValid(repoID); !valid { return RepositoryID{}, err diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 2268b8b0fb..4dc1f1938c 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2018 The Gitea Authors. All rights reserved. // Copyright 2014 The Gogs Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -156,6 +157,9 @@ func NewFuncMap() template.FuncMap { "MermaidMaxSourceCharacters": func() int { return setting.MermaidMaxSourceCharacters }, + "FederationEnabled": func() bool { + return setting.Federation.Enabled + }, // ----------------------------------------------------------------- // render diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 28f4d4b8ed..721ad1605e 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1145,6 +1145,8 @@ form.reach_limit_of_creation_1 = The owner has already reached the limit of %d r form.reach_limit_of_creation_n = The owner has already reached the limit of %d repositories. form.name_reserved = The repository name "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a repository name. +form.string_too_long=The given string is longer than %d characters. + need_auth = Authorization migrate_options = Migration options @@ -2108,6 +2110,10 @@ settings.collaboration.undefined = Undefined settings.hooks = Webhooks settings.githooks = Git hooks settings.basic_settings = Basic settings +settings.federation_settings=Federation Settings +settings.federation_apapiurl=Federation URL of this repository. Copy and paste this into Federation Settings of another repository as an URL of a Following Repository. +settings.federation_following_repos=URLs of Following Repositories. Separated by ";", no whitespace. +settings.federation_not_enabled=Federation is not enabled on your instance. settings.mirror_settings = Mirror settings settings.mirror_settings.docs = Set up your repository to automatically synchronize commits, tags and branches with another repository. settings.mirror_settings.docs.disabled_pull_mirror.instructions = Set up your project to automatically push commits, tags and branches to another repository. Pull mirrors have been disabled by your site administrator. diff --git a/release-notes/8.0.0/feat/3886.md b/release-notes/8.0.0/feat/3886.md new file mode 100644 index 0000000000..262f5ed8f6 --- /dev/null +++ b/release-notes/8.0.0/feat/3886.md @@ -0,0 +1 @@ +For federated-star we introduce a new repository setting to define following repositories. That is a workaround till we find a better way to express repository federation. diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index a7d4e75ff6..66e96b9961 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -1,5 +1,6 @@ // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package setting @@ -33,6 +34,7 @@ import ( actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/federation" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" mirror_service "code.gitea.io/gitea/services/mirror" @@ -383,6 +385,40 @@ func SettingsPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(repo.Link() + "/settings") + case "federation": + if !setting.Federation.Enabled { + ctx.NotFound("", nil) + ctx.Flash.Info(ctx.Tr("repo.settings.federation_not_enabled")) + return + } + followingRepos := strings.TrimSpace(form.FollowingRepos) + followingRepos = strings.TrimSuffix(followingRepos, ";") + + maxFollowingRepoStrLength := 2048 + errs := validation.ValidateMaxLen(followingRepos, maxFollowingRepoStrLength, "federationRepos") + if len(errs) > 0 { + ctx.Data["ERR_FollowingRepos"] = true + ctx.Flash.Error(ctx.Tr("repo.form.string_too_long", maxFollowingRepoStrLength)) + ctx.Redirect(repo.Link() + "/settings") + return + } + + federationRepoSplit := []string{} + if followingRepos != "" { + federationRepoSplit = strings.Split(followingRepos, ";") + } + for idx, repo := range federationRepoSplit { + federationRepoSplit[idx] = strings.TrimSpace(repo) + } + + if _, _, err := federation.StoreFollowingRepoList(ctx, ctx.Repo.Repository.ID, federationRepoSplit); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + case "mirror": if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { ctx.NotFound("", nil) diff --git a/services/context/repo.go b/services/context/repo.go index 54453cc2d9..e4cacbc53c 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -386,6 +387,21 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) { ctx.Data["HasAccess"] = true ctx.Data["Permission"] = &ctx.Repo.Permission + followingRepoList, err := repo_model.FindFollowingReposByRepoID(ctx, repo.ID) + if err == nil { + followingRepoString := "" + for idx, followingRepo := range followingRepoList { + if idx > 0 { + followingRepoString += ";" + } + followingRepoString += followingRepo.URI + } + ctx.Data["FollowingRepos"] = followingRepoString + } else if err != repo_model.ErrMirrorNotExist { + ctx.ServerError("FindFollowingRepoByRepoID", err) + return + } + if repo.IsMirror { pullMirror, err := repo_model.GetMirrorByRepoID(ctx, repo.ID) if err == nil { @@ -566,6 +582,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { ctx.Data["Title"] = owner.Name + "/" + repo.Name ctx.Data["Repository"] = repo + ctx.Data["RepositoryAPActorID"] = repo.APActorID() ctx.Data["Owner"] = ctx.Repo.Repository.Owner ctx.Data["IsRepositoryOwner"] = ctx.Repo.IsOwner() ctx.Data["IsRepositoryAdmin"] = ctx.Repo.IsAdmin() diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index be2dc2eb6a..1c99f784bc 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -212,3 +212,33 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI return &newUser, &federatedUser, nil } + +// Create or update a list of FollowingRepo structs +func StoreFollowingRepoList(ctx context.Context, localRepoID int64, followingRepoList []string) (int, string, error) { + followingRepos := make([]*repo.FollowingRepo, 0, len(followingRepoList)) + for _, uri := range followingRepoList { + federationHost, err := GetFederationHostForURI(ctx, uri) + if err != nil { + return http.StatusInternalServerError, "Wrong FederationHost", err + } + followingRepoID, err := fm.NewRepositoryID(uri, string(federationHost.NodeInfo.SoftwareName)) + if err != nil { + return http.StatusNotAcceptable, "Invalid federated repo", err + } + followingRepo, err := repo.NewFollowingRepo(localRepoID, followingRepoID.ID, federationHost.ID, uri) + if err != nil { + return http.StatusNotAcceptable, "Invalid federated repo", err + } + followingRepos = append(followingRepos, &followingRepo) + } + + if err := repo.StoreFollowingRepos(ctx, localRepoID, followingRepos); err != nil { + return 0, "", err + } + + return 0, "", nil +} + +func DeleteFollowingRepos(ctx context.Context, localRepoID int64) error { + return repo.StoreFollowingRepos(ctx, localRepoID, []*repo.FollowingRepo{}) +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e4fcf8e0c0..e826d179ed 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -113,6 +114,7 @@ type RepoSettingForm struct { RepoName string `binding:"Required;AlphaDashDot;MaxSize(100)"` Description string `binding:"MaxSize(2048)"` Website string `binding:"ValidUrl;MaxSize(1024)"` + FollowingRepos string Interval string MirrorAddress string MirrorUsername string diff --git a/services/repository/repository.go b/services/repository/repository.go index b7aac3cfe0..116e24151b 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -21,6 +22,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + federation_service "code.gitea.io/gitea/services/federation" notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" ) @@ -66,6 +68,10 @@ func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_mod return err } + if err := federation_service.DeleteFollowingRepos(ctx, repo.ID); err != nil { + return err + } + return packages_model.UnlinkRepositoryFromAllPackages(ctx, repo.ID) } diff --git a/services/user/user.go b/services/user/user.go index 9dc4f6fe62..4e983eb9f6 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -208,6 +209,13 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return err } } + + // Delete Federated Users + if setting.Federation.Enabled { + if err := user_model.DeleteFederatedUser(ctx, u.ID); err != nil { + return err + } + } } ctx, committer, err := db.TxContext(ctx) diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 2cdf6b0aff..440486d931 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -64,6 +64,28 @@ + {{if FederationEnabled}} +

+ {{ctx.Locale.Tr "repo.settings.federation_settings"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+

{{ctx.Locale.Tr "repo.settings.federation_apapiurl"}}

+

{{.RepositoryAPActorID}}

+
+ + +
+
+ +
+
+
+ {{end}} + {{/* These variables exist to make the logic in the Settings window easier to comprehend and are not used later on. */}} {{$newMirrorsPartiallyEnabled := or (not .DisableNewPullMirrors) (not .DisableNewPushMirrors)}} {{/* .Repository.IsMirror is not always reliable if the repository is not actively acting as a mirror because of errors. */}} diff --git a/tests/integration/repo_settings_test.go b/tests/integration/repo_settings_test.go index de86cba77a..584c1024de 100644 --- a/tests/integration/repo_settings_test.go +++ b/tests/integration/repo_settings_test.go @@ -6,9 +6,11 @@ package integration import ( "fmt" "net/http" + "net/http/httptest" "testing" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/forgefed" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" @@ -263,3 +265,59 @@ func TestProtectedBranch(t *testing.T) { unittest.AssertCount(t, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID}, 1) }) } + +func TestRepoFollowing(t *testing.T) { + setting.Federation.Enabled = true + defer tests.PrepareTestEnv(t)() + defer func() { + setting.Federation.Enabled = false + }() + + federatedRoutes := http.NewServeMux() + federatedRoutes.HandleFunc("/.well-known/nodeinfo", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo + responseBody := fmt.Sprintf(`{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host) + t.Logf("response: %s", responseBody) + // TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8 + fmt.Fprint(res, responseBody) + }) + federatedRoutes.HandleFunc("/api/v1/nodeinfo", + func(res http.ResponseWriter, req *http.Request) { + // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo + responseBody := fmt.Sprintf(`{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",` + + `"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},` + + `"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},` + + `"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`) + fmt.Fprint(res, responseBody) + }) + federatedRoutes.HandleFunc("/", + func(res http.ResponseWriter, req *http.Request) { + t.Errorf("Unhandled request: %q", req.URL.EscapedPath()) + }) + federatedSrv := httptest.NewServer(federatedRoutes) + defer federatedSrv.Close() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user.ID}) + session := loginUser(t, user.Name) + + t.Run("Add a following repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + link := fmt.Sprintf("/%s/settings", repo.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "action": "federation", + "following_repos": fmt.Sprintf("%s/api/v1/activitypub/repository-id/1", federatedSrv.URL), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + // Verify it was added. + federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"}) + unittest.AssertExistsAndLoadBean(t, &repo_model.FollowingRepo{ + ExternalID: "1", + FederationHostID: federationHost.ID, + }) + }) +}