gotosocial/internal/federation/dereferencing/account_test.go
nicole mikołajczyk bfc8c31e5f [feature] Support incoming avatar/header descriptions (#4275)
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>

# Description

Follow-up to #4270

Closes https://codeberg.org/superseriousbusiness/gotosocial/issues/3450

## Checklist

- [x] I/we have read the [GoToSocial contribution guidelines](https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md).
- [ ] I/we have discussed the proposed changes already, either in an issue on the repository, or in the Matrix chat.
- [x] I/we have not leveraged AI to create the proposed changes.
- [x] I/we have performed a self-review of added code.
- [x] I/we have written code that is legible and maintainable by others.
- [x] I/we have commented the added code, particularly in hard-to-understand areas.
- [ ] I/we have made any necessary changes to documentation.
- [x] I/we have added tests that cover new code.
- [x] I/we have run tests and they pass locally with the changes.
- [x] I/we have run `go fmt ./...` and `golangci-lint run`.

Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4275
Co-authored-by: nicole mikołajczyk <git@mkljczk.pl>
Co-committed-by: nicole mikołajczyk <git@mkljczk.pl>
2025-06-19 15:10:41 +02:00

568 lines
18 KiB
Go

// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package dereferencing_test
import (
"bytes"
"context"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"code.superseriousbusiness.org/activity/streams"
"code.superseriousbusiness.org/activity/streams/vocab"
"code.superseriousbusiness.org/gotosocial/internal/ap"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/db"
"code.superseriousbusiness.org/gotosocial/internal/federation/dereferencing"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/testrig"
"github.com/stretchr/testify/suite"
)
type AccountTestSuite struct {
DereferencerStandardTestSuite
}
func (suite *AccountTestSuite) TestDereferenceGroup() {
fetchingAccount := suite.testAccounts["local_account_1"]
groupURL := testrig.URLMustParse("https://unknown-instance.com/groups/some_group")
group, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
groupURL,
false,
)
suite.NoError(err)
suite.NotNil(group)
// group values should be set
suite.Equal("https://unknown-instance.com/groups/some_group", group.URI)
suite.Equal("https://unknown-instance.com/@some_group", group.URL)
suite.WithinDuration(time.Now(), group.FetchedAt, 5*time.Second)
// group should be in the database
dbGroup, err := suite.db.GetAccountByURI(suite.T().Context(), group.URI)
suite.NoError(err)
suite.Equal(group.ID, dbGroup.ID)
suite.Equal(ap.ActorGroup, dbGroup.ActorType.String())
}
func (suite *AccountTestSuite) TestDereferenceService() {
fetchingAccount := suite.testAccounts["local_account_1"]
serviceURL := testrig.URLMustParse("https://owncast.example.org/federation/user/rgh")
service, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
serviceURL,
false,
)
suite.NoError(err)
suite.NotNil(service)
// service values should be set
suite.Equal("https://owncast.example.org/federation/user/rgh", service.URI)
suite.Equal("https://owncast.example.org/federation/user/rgh", service.URL)
suite.WithinDuration(time.Now(), service.FetchedAt, 5*time.Second)
// service should be in the database
dbService, err := suite.db.GetAccountByURI(suite.T().Context(), service.URI)
suite.NoError(err)
suite.Equal(service.ID, dbService.ID)
suite.Equal(ap.ActorService, dbService.ActorType.String())
suite.Equal("example.org", dbService.Domain)
}
/*
We shouldn't try webfingering or making http calls to dereference local accounts
that might be passed into GetRemoteAccount for whatever reason, so these tests are
here to make sure that such cases are (basically) short-circuit evaluated and given
back as-is without trying to make any calls to one's own instance.
*/
func (suite *AccountTestSuite) TestDereferenceLocalAccountAsRemoteURL() {
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
fetchedAccount, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
testrig.URLMustParse(targetAccount.URI),
false,
)
suite.NoError(err)
suite.NotNil(fetchedAccount)
suite.Empty(fetchedAccount.Domain)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountAsRemoteURLNoSharedInboxYet() {
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
targetAccount.SharedInboxURI = nil
if err := suite.db.UpdateAccount(suite.T().Context(), targetAccount); err != nil {
suite.FailNow(err.Error())
}
fetchedAccount, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
testrig.URLMustParse(targetAccount.URI),
false,
)
suite.NoError(err)
suite.NotNil(fetchedAccount)
suite.Empty(fetchedAccount.Domain)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountAsUsername() {
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
fetchedAccount, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
testrig.URLMustParse(targetAccount.URI),
false,
)
suite.NoError(err)
suite.NotNil(fetchedAccount)
suite.Empty(fetchedAccount.Domain)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountAsUsernameDomain() {
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
fetchedAccount, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
testrig.URLMustParse(targetAccount.URI),
false,
)
suite.NoError(err)
suite.NotNil(fetchedAccount)
suite.Empty(fetchedAccount.Domain)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountAsUsernameDomainAndURL() {
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
fetchedAccount, _, err := suite.dereferencer.GetAccountByUsernameDomain(
suite.T().Context(),
fetchingAccount.Username,
targetAccount.Username,
config.GetHost(),
)
suite.NoError(err)
suite.NotNil(fetchedAccount)
suite.Empty(fetchedAccount.Domain)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUsername() {
fetchingAccount := suite.testAccounts["local_account_1"]
fetchedAccount, _, err := suite.dereferencer.GetAccountByUsernameDomain(
suite.T().Context(),
fetchingAccount.Username,
"thisaccountdoesnotexist",
config.GetHost(),
)
suite.True(gtserror.IsUnretrievable(err))
suite.EqualError(err, db.ErrNoEntries.Error())
suite.Nil(fetchedAccount)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUsernameDomain() {
fetchingAccount := suite.testAccounts["local_account_1"]
fetchedAccount, _, err := suite.dereferencer.GetAccountByUsernameDomain(
suite.T().Context(),
fetchingAccount.Username,
"thisaccountdoesnotexist",
"localhost:8080",
)
suite.True(gtserror.IsUnretrievable(err))
suite.EqualError(err, db.ErrNoEntries.Error())
suite.Nil(fetchedAccount)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUserURI() {
fetchingAccount := suite.testAccounts["local_account_1"]
fetchedAccount, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
testrig.URLMustParse("http://localhost:8080/users/thisaccountdoesnotexist"),
false,
)
suite.True(gtserror.IsUnretrievable(err))
suite.EqualError(err, db.ErrNoEntries.Error())
suite.Nil(fetchedAccount)
}
func (suite *AccountTestSuite) TestDereferenceLocalAccountByRedirect() {
ctx, cncl := context.WithCancel(suite.T().Context())
defer cncl()
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
// Convert the target account to ActivityStreams model for dereference.
targetAccountable, err := suite.converter.AccountToAS(ctx, targetAccount)
suite.NoError(err)
suite.NotNil(targetAccountable)
// Serialize to "raw" JSON map for response.
rawJSON, err := ap.Serialize(targetAccountable)
suite.NoError(err)
// Finally serialize to actual bytes.
json, err := json.Marshal(rawJSON)
suite.NoError(err)
// Replace test HTTP client with one that always returns the target account AS model.
suite.client = testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
ContentLength: int64(len(json)),
Header: http.Header{"Content-Type": {"application/activity+json"}},
Body: io.NopCloser(bytes.NewReader(json)),
Request: &http.Request{URL: testrig.URLMustParse(targetAccount.URI)},
}, nil
}, "")
// Update dereferencer to use new test HTTP client.
suite.dereferencer = dereferencing.NewDereferencer(
&suite.state,
suite.converter,
testrig.NewTestTransportController(&suite.state, suite.client),
suite.visFilter,
suite.intFilter,
suite.media,
)
// Use any old input test URI, this doesn't actually matter what it is.
uri := testrig.URLMustParse("https://this-will-be-redirected.butts/")
// Try dereference the test URI, since it correctly redirects to us it should return our account.
account, accountable, err := suite.dereferencer.GetAccountByURI(ctx, fetchingAccount.Username, uri, false)
suite.NoError(err)
suite.Nil(accountable)
suite.NotNil(account)
suite.Equal(targetAccount.ID, account.ID)
}
func (suite *AccountTestSuite) TestDereferenceMasqueradingLocalAccount() {
ctx, cncl := context.WithCancel(suite.T().Context())
defer cncl()
fetchingAccount := suite.testAccounts["local_account_1"]
targetAccount := suite.testAccounts["local_account_2"]
// Convert the target account to ActivityStreams model for dereference.
targetAccountable, err := suite.converter.AccountToAS(ctx, targetAccount)
suite.NoError(err)
suite.NotNil(targetAccountable)
// Serialize to "raw" JSON map for response.
rawJSON, err := ap.Serialize(targetAccountable)
suite.NoError(err)
// Finally serialize to actual bytes.
json, err := json.Marshal(rawJSON)
suite.NoError(err)
// Use any old input test URI, this doesn't actually matter what it is.
uri := testrig.URLMustParse("https://this-will-be-redirected.butts/")
// Replace test HTTP client with one that returns OUR account, but at their URI endpoint.
suite.client = testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
return &http.Response{
Status: http.StatusText(http.StatusOK),
StatusCode: http.StatusOK,
ContentLength: int64(len(json)),
Header: http.Header{"Content-Type": {"application/activity+json"}},
Body: io.NopCloser(bytes.NewReader(json)),
Request: &http.Request{URL: uri},
}, nil
}, "")
// Update dereferencer to use new test HTTP client.
suite.dereferencer = dereferencing.NewDereferencer(
&suite.state,
suite.converter,
testrig.NewTestTransportController(&suite.state, suite.client),
suite.visFilter,
suite.intFilter,
suite.media,
)
// Try dereference the test URI, since it correctly redirects to us it should return our account.
account, accountable, err := suite.dereferencer.GetAccountByURI(ctx, fetchingAccount.Username, uri, false)
suite.NotNil(err)
suite.Nil(account)
suite.Nil(accountable)
}
func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithNonMatchingURI() {
fetchingAccount := suite.testAccounts["local_account_1"]
const (
remoteURI = "https://turnip.farm/users/turniplover6969"
remoteAltURI = "https://turnip.farm/users/turniphater420"
)
// Create a copy of this remote account at alternative URI.
remotePerson := suite.client.TestRemotePeople[remoteURI]
suite.client.TestRemotePeople[remoteAltURI] = remotePerson
// Attempt to fetch account at alternative URI, it should fail!
fetchedAccount, _, err := suite.dereferencer.GetAccountByURI(
suite.T().Context(),
fetchingAccount.Username,
testrig.URLMustParse(remoteAltURI),
false,
)
suite.Equal(err.Error(), fmt.Sprintf("enrichAccount: account uri %s does not match %s", remoteURI, remoteAltURI))
suite.Nil(fetchedAccount)
}
func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithUnexpectedKeyChange() {
ctx, cncl := context.WithCancel(suite.T().Context())
defer cncl()
fetchingAcc := suite.testAccounts["local_account_1"]
remoteURI := "https://turnip.farm/users/turniplover6969"
// Fetch the remote account to load into the database.
remoteAcc, _, err := suite.dereferencer.GetAccountByURI(ctx,
fetchingAcc.Username,
testrig.URLMustParse(remoteURI),
false,
)
suite.NoError(err)
suite.NotNil(remoteAcc)
// Mark account as requiring a refetch.
remoteAcc.FetchedAt = time.Time{}
err = suite.state.DB.UpdateAccount(ctx, remoteAcc, "fetched_at")
suite.NoError(err)
// Update remote to have an unexpected different key.
remotePerson := suite.client.TestRemotePeople[remoteURI]
setPublicKey(remotePerson,
remoteURI,
fetchingAcc.PublicKeyURI+".unique",
fetchingAcc.PublicKey,
)
// Force refresh account expecting key change error.
_, _, err = suite.dereferencer.RefreshAccount(ctx,
fetchingAcc.Username,
remoteAcc,
nil,
nil,
)
suite.Equal(err.Error(), fmt.Sprintf("RefreshAccount: enrichAccount: account %s pubkey has changed (key rotation required?)", remoteURI))
}
func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithExpectedKeyChange() {
ctx, cncl := context.WithCancel(suite.T().Context())
defer cncl()
fetchingAcc := suite.testAccounts["local_account_1"]
remoteURI := "https://turnip.farm/users/turniplover6969"
// Fetch the remote account to load into the database.
remoteAcc, _, err := suite.dereferencer.GetAccountByURI(ctx,
fetchingAcc.Username,
testrig.URLMustParse(remoteURI),
false,
)
suite.NoError(err)
suite.NotNil(remoteAcc)
// Expire the remote account's public key.
remoteAcc.PublicKeyExpiresAt = time.Now()
remoteAcc.FetchedAt = time.Time{} // force fetch
err = suite.state.DB.UpdateAccount(ctx, remoteAcc, "fetched_at", "public_key_expires_at")
suite.NoError(err)
// Update remote to have a different stored public key.
remotePerson := suite.client.TestRemotePeople[remoteURI]
setPublicKey(remotePerson,
remoteURI,
fetchingAcc.PublicKeyURI+".unique",
fetchingAcc.PublicKey,
)
// Refresh account expecting a succesful refresh with changed keys!
updatedAcc, apAcc, err := suite.dereferencer.RefreshAccount(ctx,
fetchingAcc.Username,
remoteAcc,
nil,
nil,
)
suite.NoError(err)
suite.NotNil(apAcc)
suite.True(updatedAcc.PublicKey.Equal(fetchingAcc.PublicKey))
}
func (suite *AccountTestSuite) TestRefreshFederatedRemoteAccountWithKeyChange() {
ctx, cncl := context.WithCancel(suite.T().Context())
defer cncl()
fetchingAcc := suite.testAccounts["local_account_1"]
remoteURI := "https://turnip.farm/users/turniplover6969"
// Fetch the remote account to load into the database.
remoteAcc, _, err := suite.dereferencer.GetAccountByURI(ctx,
fetchingAcc.Username,
testrig.URLMustParse(remoteURI),
false,
)
suite.NoError(err)
suite.NotNil(remoteAcc)
// Update remote to have a different stored public key.
remotePerson := suite.client.TestRemotePeople[remoteURI]
setPublicKey(remotePerson,
remoteURI,
fetchingAcc.PublicKeyURI+".unique",
fetchingAcc.PublicKey,
)
// Refresh account expecting a succesful refresh with changed keys!
// By passing in the remote person model this indicates that the data
// was received via the federator, which should trust any key change.
updatedAcc, apAcc, err := suite.dereferencer.RefreshAccount(ctx,
fetchingAcc.Username,
remoteAcc,
remotePerson,
nil,
)
suite.NoError(err)
suite.NotNil(apAcc)
suite.True(updatedAcc.PublicKey.Equal(fetchingAcc.PublicKey))
}
func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithAvatarDescription() {
ctx, cncl := context.WithCancel(suite.T().Context())
defer cncl()
fetchingAcc := suite.testAccounts["local_account_1"]
remoteURI := "https://shrimpnet.example.org/users/shrimp"
description := "me scrolling fedi on a laptop, there's a monster ultra white and another fedi user on my right."
// Fetch the remote account to load into the database.
remoteAcc, _, err := suite.dereferencer.GetAccountByURI(ctx,
fetchingAcc.Username,
testrig.URLMustParse(remoteURI),
false,
)
suite.NoError(err)
suite.NotNil(remoteAcc)
suite.Equal(remoteAcc.AvatarMediaAttachment.Description, description)
remotePerson := suite.client.TestRemotePeople[remoteURI]
description = strings.TrimSuffix(description, ".")
icon := remotePerson.GetActivityStreamsIcon()
image := icon.Begin().GetActivityStreamsImage()
nameProp := streams.NewActivityStreamsNameProperty()
nameProp.AppendXMLSchemaString(description)
image.SetActivityStreamsName(nameProp)
icon.SetActivityStreamsImage(0, image)
remotePerson.SetActivityStreamsIcon(icon)
updatedAcc, apAcc, err := suite.dereferencer.RefreshAccount(ctx,
fetchingAcc.Username,
remoteAcc,
remotePerson,
nil,
)
suite.NoError(err)
suite.NotNil(apAcc)
suite.Equal(updatedAcc.AvatarMediaAttachment.Description, description)
}
func TestAccountTestSuite(t *testing.T) {
suite.Run(t, new(AccountTestSuite))
}
func setPublicKey(person vocab.ActivityStreamsPerson, ownerURI, keyURI string, key *rsa.PublicKey) {
profileIDURI, err := url.Parse(ownerURI)
if err != nil {
panic(err)
}
publicKeyURI, err := url.Parse(keyURI)
if err != nil {
panic(err)
}
publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
// create the public key
publicKey := streams.NewW3IDSecurityV1PublicKey()
// set ID for the public key
publicKeyIDProp := streams.NewJSONLDIdProperty()
publicKeyIDProp.SetIRI(publicKeyURI)
publicKey.SetJSONLDId(publicKeyIDProp)
// set owner for the public key
publicKeyOwnerProp := streams.NewW3IDSecurityV1OwnerProperty()
publicKeyOwnerProp.SetIRI(profileIDURI)
publicKey.SetW3IDSecurityV1Owner(publicKeyOwnerProp)
// set the pem key itself
encodedPublicKey, err := x509.MarshalPKIXPublicKey(key)
if err != nil {
panic(err)
}
publicKeyBytes := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: encodedPublicKey,
})
publicKeyPEMProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
publicKeyPEMProp.Set(string(publicKeyBytes))
publicKey.SetW3IDSecurityV1PublicKeyPem(publicKeyPEMProp)
// append the public key to the public key property
publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKey)
// set the public key property on the Person
person.SetW3IDSecurityV1PublicKey(publicKeyProp)
}