diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go new file mode 100644 index 0000000000..42232bd640 --- /dev/null +++ b/tests/integration/api_activitypub_actor_test.go @@ -0,0 +1,77 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "testing" + + "forgejo.org/modules/forgefed" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/routers" + "forgejo.org/services/contexttest" + "forgejo.org/services/federation" + "forgejo.org/tests" + + ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubActor(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + defer tests.PrepareTestEnv(t)() + + req := NewRequest(t, "GET", "/api/v1/activitypub/actor") + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), "@context") + + var actor ap.Actor + err := actor.UnmarshalJSON(resp.Body.Bytes()) + require.NoError(t, err) + + assert.Equal(t, ap.ApplicationType, actor.Type) + assert.Equal(t, "ghost", actor.PreferredUsername.String()) + keyID := actor.GetID().String() + assert.Regexp(t, "activitypub/actor$", keyID) + assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String()) + + pubKey := actor.PublicKey + assert.NotNil(t, pubKey) + publicKeyID := keyID + "#main-key" + assert.Equal(t, pubKey.ID.String(), publicKeyID) + + pubKeyPem := pubKey.PublicKeyPem + assert.NotNil(t, pubKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) +} + +func TestActorNewFromKeyId(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + ctx, _ := contexttest.MockAPIContext(t, "/api/v1/activitypub/actor") + sut, err := federation.NewActorIDFromKeyID(ctx.Base, fmt.Sprintf("%sapi/v1/activitypub/actor#main-key", u)) + require.NoError(t, err) + + port, err := strconv.ParseUint(u.Port(), 10, 16) + require.NoError(t, err) + + assert.Equal(t, forgefed.ActorID{ + ID: "actor", + HostSchema: "http", + Path: "api/v1/activitypub", + Host: setting.Domain, + HostPort: uint16(port), + UnvalidatedInput: fmt.Sprintf("http://%s:%d/api/v1/activitypub/actor", setting.Domain, port), + IsPortSupplemented: false, + }, sut) + }) +} diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go new file mode 100644 index 0000000000..277b150a1e --- /dev/null +++ b/tests/integration/api_activitypub_person_test.go @@ -0,0 +1,113 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/routers" + "forgejo.org/services/contexttest" + "forgejo.org/tests" + + ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubPerson(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + defer federatedSrv.Close() + + onGiteaRun(t, func(t *testing.T, localUrl *url.URL) { + defer test.MockVariableValue(&setting.AppURL, localUrl.String())() + + localUserID := 2 + localUserName := "user2" + localUserURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", localUrl, localUserID) + + // distantURL := federatedSrv.URL + // distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL) + + // Unsigned request + t.Run("UnsignedRequest", func(t *testing.T) { + req := NewRequest(t, "GET", localUserURL) + MakeRequest(t, req, http.StatusBadRequest) + }) + + // Signed request + t.Run("SignedRequestValidation", func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, localUserURL) + cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second) + require.NoError(t, err) + + c, err := cf.WithKeysDirect(ctx, mock.Persons[0].PrivKey, + mock.Persons[0].KeyID(federatedSrv.URL)) + require.NoError(t, err) + + resp, err := c.GetBody(localUserURL) + require.NoError(t, err) + + var person ap.Person + err = person.UnmarshalJSON(resp) + require.NoError(t, err) + + assert.Equal(t, ap.PersonType, person.Type) + assert.Equal(t, localUserName, person.PreferredUsername.String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", localUserID), person.GetID()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/inbox$", localUserID), person.Inbox.GetID().String()) + + assert.NotNil(t, person.PublicKey) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d#main-key$", localUserID), person.PublicKey.ID) + + assert.NotNil(t, person.PublicKey.PublicKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", person.PublicKey.PublicKeyPem) + }) + }) +} + +func TestActivityPubMissingPerson(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + req := NewRequest(t, "GET", "/api/v1/activitypub/user-id/999999999") + resp := MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "user does not exist") +} + +func TestActivityPubPersonInbox(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer test.MockVariableValue(&setting.AppURL, u.String())() + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + user1url := u.JoinPath("/api/v1/activitypub/user-id/1").String() + "#main-key" + user2inboxurl := u.JoinPath("/api/v1/activitypub/user-id/2/inbox").String() + ctx, _ := contexttest.MockAPIContext(t, user2inboxurl) + cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second) + require.NoError(t, err) + c, err := cf.WithKeys(ctx, user1, user1url) + require.NoError(t, err) + + // Signed request "succeeds" + resp, err := c.Post([]byte{}, user2inboxurl) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + }) +} diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go new file mode 100644 index 0000000000..b4be0407b9 --- /dev/null +++ b/tests/integration/api_activitypub_repository_test.go @@ -0,0 +1,176 @@ +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "forgejo.org/models/forgefed" + "forgejo.org/models/unittest" + "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + forgefed_modules "forgejo.org/modules/forgefed" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/routers" + "forgejo.org/services/contexttest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubRepository(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + defer federatedSrv.Close() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + repositoryID := 2 + + localRepository := fmt.Sprintf("%sapi/v1/activitypub/repository-id/%d", u, repositoryID) + + ctx, _ := contexttest.MockAPIContext(t, localRepository) + cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second) + require.NoError(t, err) + + c, err := cf.WithKeysDirect(ctx, mock.Persons[0].PrivKey, + mock.Persons[0].KeyID(federatedSrv.URL)) + require.NoError(t, err) + + resp, err := c.GetBody(localRepository) + require.NoError(t, err) + assert.Contains(t, string(resp), "@context") + + var repository forgefed_modules.Repository + err = repository.UnmarshalJSON(resp) + require.NoError(t, err) + + assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%d$", repositoryID), repository.GetID().String()) + }) +} + +func TestActivityPubMissingRepository(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + repositoryID := 9999999 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID)) + resp := MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "repository does not exist") +} + +func TestActivityPubRepositoryInboxValid(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + defer federatedSrv.Close() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + repositoryID := 2 + timeNow := time.Now().UTC() + localRepoInbox := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() + + ctx, _ := contexttest.MockAPIContext(t, localRepoInbox) + cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second) + require.NoError(t, err) + + c, err := cf.WithKeysDirect(ctx, mock.Persons[0].PrivKey, + mock.Persons[0].KeyID(federatedSrv.URL)) + require.NoError(t, err) + + activity1 := []byte(fmt.Sprintf( + `{"type":"Like",`+ + `"startTime":"%s",`+ + `"actor":"%s/api/v1/activitypub/user-id/15",`+ + `"object":"%s"}`, + timeNow.Format(time.RFC3339), + federatedSrv.URL, u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID)).String())) + t.Logf("activity: %s", activity1) + resp, err := c.Post(activity1, localRepoInbox) + + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"}) + federatedUser := unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "15", FederationHostID: federationHost.ID}) + unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID}) + + // A like activity by a different user of the same federated host. + activity2 := []byte(fmt.Sprintf( + `{"type":"Like",`+ + `"startTime":"%s",`+ + `"actor":"%s/api/v1/activitypub/user-id/30",`+ + `"object":"%s"}`, + // Make sure this activity happens later then the one before + timeNow.Add(time.Second).Format(time.RFC3339), + federatedSrv.URL, u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d", repositoryID)).String())) + t.Logf("activity: %s", activity2) + resp, err = c.Post(activity2, localRepoInbox) + + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + federatedUser = unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "30", FederationHostID: federationHost.ID}) + unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID}) + + // The same user sends another like activity + otherRepositoryID := 3 + otherRepoInboxURL := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", otherRepositoryID)).String() + activity3 := []byte(fmt.Sprintf( + `{"type":"Like",`+ + `"startTime":"%s",`+ + `"actor":"%s/api/v1/activitypub/user-id/30",`+ + `"object":"%s"}`, + // Make sure this activity happens later then the ones before + timeNow.Add(time.Second*2).Format(time.RFC3339), + federatedSrv.URL, u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d", otherRepositoryID)).String())) + t.Logf("activity: %s", activity3) + resp, err = c.Post(activity3, otherRepoInboxURL) + + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + federatedUser = unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "30", FederationHostID: federationHost.ID}) + unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID}) + + // Replay activity2. + resp, err = c.Post(activity2, localRepoInbox) + require.NoError(t, err) + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) + }) +} + +func TestActivityPubRepositoryInboxInvalid(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + apServerActor := user.NewAPServerActor() + repositoryID := 2 + localRepo2Inbox := u.JoinPath(fmt.Sprintf("/api/v1/activitypub/repository-id/%d/inbox", repositoryID)).String() + + ctx, _ := contexttest.MockAPIContext(t, localRepo2Inbox) + cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second) + require.NoError(t, err) + + c, err := cf.WithKeys(ctx, apServerActor, apServerActor.KeyID()) + require.NoError(t, err) + + activity := []byte(`{"type":"Wrong"}`) + resp, err := c.Post(activity, localRepo2Inbox) + require.NoError(t, err) + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) + }) +} diff --git a/tests/integration/api_federation_httpsig_test.go b/tests/integration/api_federation_httpsig_test.go new file mode 100644 index 0000000000..a8deaa315f --- /dev/null +++ b/tests/integration/api_federation_httpsig_test.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "forgejo.org/models/db" + "forgejo.org/models/forgefed" + "forgejo.org/models/unittest" + "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/routers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFederationHttpSigValidation(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + userID := 2 + userURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", u, userID) + + user1 := unittest.AssertExistsAndLoadBean(t, &user.User{ID: 1}) + + clientFactory, err := activitypub.GetClientFactory(db.DefaultContext) + require.NoError(t, err) + + apClient, err := clientFactory.WithKeys(db.DefaultContext, user1, user1.KeyID()) + require.NoError(t, err) + + // Unsigned request + t.Run("UnsignedRequest", func(t *testing.T) { + req := NewRequest(t, "GET", userURL) + MakeRequest(t, req, http.StatusBadRequest) + }) + + // Signed request + t.Run("SignedRequest", func(t *testing.T) { + resp, err := apClient.Get(userURL) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + // HACK HACK HACK: the host part of the URL gets set to which IP forgejo is + // listening on, NOT localhost, which is the Domain given to forgejo which + // is then used for eg. the keyID all requests + applicationKeyID := fmt.Sprintf("%sapi/v1/activitypub/actor#main-key", setting.AppURL) + actorKeyID := fmt.Sprintf("%sapi/v1/activitypub/user-id/1#main-key", setting.AppURL) + + // Check for cached public keys + t.Run("ValidateCaches", func(t *testing.T) { + host, err := forgefed.FindFederationHostByKeyID(db.DefaultContext, applicationKeyID) + require.NoError(t, err) + assert.NotNil(t, host) + assert.True(t, host.PublicKey.Valid) + + _, user, err := user.FindFederatedUserByKeyID(db.DefaultContext, actorKeyID) + require.NoError(t, err) + assert.NotNil(t, user) + assert.True(t, user.PublicKey.Valid) + }) + + // Disable signature validation + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() + + // Unsigned request + t.Run("SignatureValidationDisabled", func(t *testing.T) { + req := NewRequest(t, "GET", userURL) + MakeRequest(t, req, http.StatusOK) + }) + }) +} diff --git a/tests/integration/repo_star_federation_test.go b/tests/integration/repo_star_federation_test.go new file mode 100644 index 0000000000..ae9a4e9f83 --- /dev/null +++ b/tests/integration/repo_star_federation_test.go @@ -0,0 +1,81 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "forgejo.org/models/forgefed" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + fm "forgejo.org/modules/forgefed" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/modules/validation" + "forgejo.org/tests" +) + +func TestActivityPubRepoFollowing(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + 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, + }) + }) + + t.Run("Star a repo having a following repo", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repoLink := fmt.Sprintf("/%s", repo.FullName()) + link := fmt.Sprintf("%s/action/star", repoLink) + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, repoLink), + }) + + session.MakeRequest(t, req, http.StatusOK) + + // Verify distant server received a like activity + like := fm.ForgeLike{} + err := like.UnmarshalJSON([]byte(mock.LastPost)) + if err != nil { + t.Errorf("Error unmarshalling ForgeLike: %q", err) + } + if isValid, err := validation.IsValid(like); !isValid { + t.Errorf("ForgeLike is not valid: %q", err) + } + activityType := like.Type + object := like.Object.GetLink().String() + isLikeType := activityType == "Like" + isCorrectObject := strings.HasSuffix(object, "/api/v1/activitypub/repository-id/1") + if !isLikeType || !isCorrectObject { + t.Error("Activity is not a like for this repo") + } + }) +}