feat: Follow remote users; feed tab (#10380)

This is hopefully the final part of PR #4767, rebased and squashed.

More thorough federation tests are at https://code.forgejo.org/forgejo/end-to-end/pulls/1276 but the mock has been extended to hopefully cover a good chunk as well.

Co-authored-by: Gergely Nagy <forgejo@gergo.csillger.hu>
Co-authored-by: Michael Jerger <michael.jerger@meissa-gmbh.de>
Co-authored-by: zam <mirco.zachmann@meissa.de>
Co-authored-by: Panagiotis "Ivory" Vasilopoulos <git@n0toose.net>

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10380
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: famfo <famfo@famfo.xyz>
Co-committed-by: famfo <famfo@famfo.xyz>
This commit is contained in:
famfo 2026-04-12 03:31:03 +02:00 committed by Gusted
parent 9de142eb7f
commit fd28fd896b
26 changed files with 599 additions and 84 deletions

View file

@ -10,6 +10,7 @@ import (
"testing"
"time"
"forgejo.org/models/db"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/activitypub"
@ -26,7 +27,6 @@ import (
// Flow of this test is documented at: https://codeberg.org/forgejo-contrib/federation/src/branch/main/doc/user-activity-following.md
func TestActivityPubPersonInboxFollow(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
federation.Init()
@ -40,6 +40,7 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
distantURL := federatedSrv.URL
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
distantUser15AliasURL := fmt.Sprintf("%s/api/v1/activitypub/user-id/alias15", distantURL)
localUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
@ -52,14 +53,16 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
`{"type":"Follow",`+
`"actor":"%s",`+
`"object":"%s"}`,
distantUser15URL,
distantUser15AliasURL,
localUser2URL,
)
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
require.NoError(t, err)
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
mock.ApActor.KeyID(federatedSrv.URL))
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, mock.ApActor.KeyID(federatedSrv.URL))
require.NoError(t, err)
resp, err := c.Post(followActivity, localUser2Inbox)
require.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
@ -94,9 +97,12 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
distantUser15URL,
localUser2URL,
)
c, err = cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
mock.ApActor.KeyID(federatedSrv.URL))
require.NoError(t, err)
resp, err = c.Post(undoFollowActivity, localUser2Inbox)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
@ -110,3 +116,44 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
)
})
}
func TestActivityPubFollowRefollow(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
require.NoError(t, federation.Init())
mock := test.NewFederationServerMock()
federatedSrv := mock.DistantServer(t)
defer federatedSrv.Close()
onApplicationRun(t, func(t *testing.T, localUrl *url.URL) {
defer test.MockVariableValue(&setting.AppURL, localUrl.String())()
distantURL := federatedSrv.URL
distantUser15AliasURL := fmt.Sprintf("%s/api/v1/activitypub/user-id/alias15", distantURL)
localUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2")
localUser2Inbox := localUrl.JoinPath("/api/v1/activitypub/user-id/2/inbox")
ctx := t.Context()
var follow user_model.FederatedUserFollower
has, err := db.GetEngine(ctx).Get(&follow)
require.NoError(t, err)
assert.False(t, has)
require.NoError(t, mock.FollowActorUnsigned(federatedSrv.URL, 15, *localUser2URL, *localUser2Inbox))
has, err = db.GetEngine(ctx).Get(&follow)
require.NoError(t, err)
assert.True(t, has)
assert.Equal(t, int64(2), follow.FollowedUserID)
apiCtx, _ := contexttest.MockAPIContext(t, localUser2Inbox.String())
err = federation.FollowRemoteActor(apiCtx, localUser, distantUser15AliasURL)
require.NoError(t, err)
})
}

View file

@ -10,6 +10,7 @@ import (
"testing"
"time"
"forgejo.org/models/activities"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
@ -26,9 +27,80 @@ import (
"github.com/stretchr/testify/require"
)
func TestActivityPubPersonInboxNoteFromDistant(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
federation.Init()
mock := test.NewFederationServerMock()
federatedSrv := mock.DistantServer(t)
defer federatedSrv.Close()
onApplicationRun(t, func(t *testing.T, localUrl *url.URL) {
defer test.MockVariableValue(&setting.AppURL, localUrl.String())()
distantURL := federatedSrv.URL
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
localUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
localUser2Inbox := localUrl.JoinPath("/api/v1/activitypub/user-id/2/inbox").String()
localSession2 := loginUser(t, localUser2.LoginName)
localSecssion2Token := getTokenForLoggedInUser(t, localSession2, auth_model.AccessTokenScopeWriteUser)
// view own empty feed on web UI
feedPage := NewHTMLParser(t, localSession2.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=feed"), http.StatusOK).Body)
feedPage.AssertElement(t, "#empty-ap-feed", true)
// follow (local follows distant)
req := NewRequestWithJSON(t, "POST",
"/api/v1/user/activitypub/follow",
&structs.APRemoteFollowOption{
Target: distantUser15URL,
}).
AddTokenAuth(localSecssion2Token)
MakeRequest(t, req, http.StatusNoContent)
// send note (distant -> local)
distantNoteURL := fmt.Sprintf("%s/api/v1/activitypub/note/104", distantURL)
userActivity := fmt.Appendf(
[]byte{},
`{"type":"Create",`+
`"actor":"%s",`+
`"to": ["https://www.w3.org/ns/activitystreams#Public"],`+
`"cc": ["%s"],`+
`"object": {"type":"Note","content":"The Content!",`+
`"url":"%s"}}`,
distantUser15URL,
localUser2URL,
distantNoteURL,
)
ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
require.NoError(t, err)
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, mock.ApActor.KeyID(federatedSrv.URL))
require.NoError(t, err)
resp, err := c.Post(userActivity, localUser2Inbox)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
// check whether user activity exists in local instance
unittest.AssertExistsAndLoadBean(t, &activities.FederatedUserActivity{NoteURL: distantNoteURL})
// view own non-empty feed on web UI
feedPage = NewHTMLParser(t, localSession2.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=feed"), http.StatusOK).Body)
feedPage.AssertElement(t, "#empty-ap-feed", false)
})
}
func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
federation.Init()
@ -60,14 +132,17 @@ func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
distantUser15URL,
localUser2URL,
)
ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
require.NoError(t, err)
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
mock.ApActor.KeyID(federatedSrv.URL))
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, mock.ApActor.KeyID(federatedSrv.URL))
require.NoError(t, err)
resp, err := c.Post(followActivity, localUser2Inbox)
require.NoError(t, err)
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
// local action which triggers a user activity
@ -87,9 +162,11 @@ func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
// distant request activity & activity note
localUser2ActivityNote := fmt.Sprintf("%v/activities/1", localUser2URL)
localUser2Activity := fmt.Sprintf("%v/activities/1/activity", localUser2URL)
resp, err = c.Get(localUser2ActivityNote)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp, err = c.Get(localUser2Activity)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)

View file

@ -0,0 +1,55 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/forgefed"
"forgejo.org/models/unittest"
"forgejo.org/models/user"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"forgejo.org/modules/test"
"forgejo.org/routers"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
)
func TestActivityPubFollowFederated(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
mock := test.NewFederationServerMock()
federatedSrv := mock.DistantServer(t)
defer federatedSrv.Close()
localUser10Name := "user10"
localSession10 := loginUser(t, localUser10Name)
localSecssion10Token := getTokenForLoggedInUser(t, localSession10, auth_model.AccessTokenScopeWriteUser)
distantURL := federatedSrv.URL
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
// local user follow distant
req := NewRequestWithJSON(t, "POST",
"/api/v1/user/activitypub/follow",
&structs.APRemoteFollowOption{
Target: distantUser15URL,
}).
AddTokenAuth(localSecssion10Token)
MakeRequest(t, req, http.StatusNoContent)
// check: federated actors now exist local
federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"})
unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "15", FederationHostID: federationHost.ID})
// check: follow request arrived at distant
assert.Contains(t, mock.LastPost, "\"object\":\"http://DISTANT_FEDERATION_HOST/api/v1/activitypub/user-id/15\"")
}

View file

@ -0,0 +1,53 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package integration
import (
"net/http"
"testing"
"forgejo.org/modules/setting"
"forgejo.org/modules/test"
"forgejo.org/routers"
"forgejo.org/tests"
"github.com/stretchr/testify/assert"
"golang.org/x/net/html"
)
func getLinks(t *testing.T, url string) []*html.Node {
req := NewRequest(t, "GET", url)
resp := MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
links := htmlDoc.doc.Find("link[type=\"application/activity+json\"]").Nodes
return links
}
func TestFederationBaseHead(t *testing.T) {
defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
t.Run("Federation disabled", func(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, false)()
links := getLinks(t, "/user1")
assert.Empty(t, links)
})
t.Run("Federation enabled", func(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
links := getLinks(t, "/user1")
assert.Len(t, links, 1)
})
t.Run("Organization", func(t *testing.T) {
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
links := getLinks(t, "/org3")
assert.Empty(t, links)
})
}