jojo/modules/test/distant_federation_server_mock.go
famfo fd28fd896b 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>
2026-04-12 03:31:03 +02:00

206 lines
6.5 KiB
Go

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package test
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"forgejo.org/modules/util"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"github.com/google/uuid"
)
type ApActorMock struct {
PrivKey string
PubKey string
}
type FederationServerMockPerson struct {
ID int64
Name string
PubKey string
PrivKey string
}
type FederationServerMockRepository struct {
ID int64
}
type FederationServerMock struct {
ApActor ApActorMock
Persons []FederationServerMockPerson
Repositories []FederationServerMockRepository
LastPost string
}
func NewApActorMock() ApActorMock {
priv, pub, _ := util.GenerateKeyPair(1024)
return ApActorMock{
PrivKey: priv,
PubKey: pub,
}
}
func (u *ApActorMock) KeyID(host string) string {
return fmt.Sprintf("%s/api/v1/activitypub/actor#main-key", host)
}
func (u *ApActorMock) marshal(host string) string {
baseID := fmt.Sprintf("http://%s/api/v1/activitypub/actor", host)
return fmt.Sprintf(
`{ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],`+
`"id": "%[1]s",`+
`"type": "Application",`+
`"preferredUsername": "ghost",`+
`"publicKey": {`+
` "id": "%[1]s#main-key",`+
` "owner": "%[1]s",`+
` "publicKeyPem": %[2]q }}`,
baseID,
u.PubKey,
)
}
func NewFederationServerMockPerson(id int64, name string) FederationServerMockPerson {
priv, pub, _ := util.GenerateKeyPair(3072)
return FederationServerMockPerson{
ID: id,
Name: name,
PubKey: pub,
PrivKey: priv,
}
}
func (p *FederationServerMockPerson) KeyID(host string) string {
return fmt.Sprintf("%[1]v/api/v1/activitypub/user-id/%[2]v#main-key", host, p.ID)
}
func (p FederationServerMockPerson) marshal(host string) string {
return fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],`+
`"id":"http://%[1]v/api/v1/activitypub/user-id/%[2]v",`+
`"type":"Person",`+
`"icon":{"type":"Image","mediaType":"image/png","url":"http://%[1]v/avatars/1bb05d9a5f6675ed0272af9ea193063c"},`+
`"url":"http://%[1]v/%[2]v",`+
`"inbox":"http://%[1]v/api/v1/activitypub/user-id/%[2]v/inbox",`+
`"outbox":"http://%[1]v/api/v1/activitypub/user-id/%[2]v/outbox",`+
`"preferredUsername":"%[3]v",`+
`"publicKey":{"id":"http://%[1]v/api/v1/activitypub/user-id/%[2]v#main-key",`+
`"owner":"http://%[1]v/api/v1/activitypub/user-id/%[2]v",`+
`"publicKeyPem":%[4]q}}`, host, p.ID, p.Name, p.PubKey)
}
func NewFederationServerMockRepository(id int64) FederationServerMockRepository {
return FederationServerMockRepository{
ID: id,
}
}
func NewFederationServerMock() *FederationServerMock {
return &FederationServerMock{
ApActor: NewApActorMock(),
Persons: []FederationServerMockPerson{
NewFederationServerMockPerson(15, "stargoose1"),
NewFederationServerMockPerson(30, "stargoose2"),
},
Repositories: []FederationServerMockRepository{
NewFederationServerMockRepository(1),
},
LastPost: "",
}
}
func (mock *FederationServerMock) recordLastPost(t *testing.T, req *http.Request) {
buf := new(strings.Builder)
_, err := io.Copy(buf, req.Body)
if err != nil {
t.Errorf("Error reading body: %q", err)
}
mock.LastPost = strings.ReplaceAll(buf.String(), req.Host, "DISTANT_FEDERATION_HOST")
}
func (mock *FederationServerMock) FollowActorUnsigned(host string, localID int64, uri, inboxURL url.URL) error {
apID := fmt.Sprintf("%s/api/v1/activitypub/user-id/%d", host, localID)
activity := ap.Follow{}
activity.Type = ap.FollowType
activity.ID = ap.IRI(apID + "/follows/" + uuid.New().String())
activity.Actor = ap.IRI(apID)
activity.Object = ap.IRI(uri.String())
payload, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI)).Marshal(activity)
if err != nil {
return err
}
reader := bytes.NewReader(payload)
_, err = http.Post(inboxURL.String(), "application/activity+json", reader)
return err
}
func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
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
// TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8
fmt.Fprintf(res, `{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host)
})
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
fmt.Fprint(res, `{"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":{}}`)
})
for _, person := range mock.Persons {
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/alias%v", person.ID),
func(res http.ResponseWriter, req *http.Request) {
fmt.Fprint(res, person.marshal(req.Host))
})
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/%v", person.ID),
func(res http.ResponseWriter, req *http.Request) {
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2
fmt.Fprint(res, person.marshal(req.Host))
})
federatedRoutes.HandleFunc(fmt.Sprintf("POST /api/v1/activitypub/user-id/%v/inbox", person.ID),
func(res http.ResponseWriter, req *http.Request) {
mock.recordLastPost(t, req)
})
}
for _, repository := range mock.Repositories {
federatedRoutes.HandleFunc(fmt.Sprintf("POST /api/v1/activitypub/repository-id/%v/inbox", repository.ID),
func(res http.ResponseWriter, req *http.Request) {
mock.recordLastPost(t, req)
})
}
federatedRoutes.HandleFunc("GET /api/v1/activitypub/actor",
func(res http.ResponseWriter, req *http.Request) {
fmt.Fprint(res, mock.ApActor.marshal(req.Host))
})
federatedRoutes.HandleFunc("/",
func(res http.ResponseWriter, req *http.Request) {
t.Errorf("Unhandled %v request: %q", req.Method, req.URL.EscapedPath())
})
federatedSrv := httptest.NewServer(federatedRoutes)
return federatedSrv
}