// 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 . package users_test import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/api/activitypub/users" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/testrig" ) type InboxPostTestSuite struct { UserStandardTestSuite } func (suite *InboxPostTestSuite) inboxPost( activity pub.Activity, requestingAccount *gtsmodel.Account, targetAccount *gtsmodel.Account, expectedHTTPStatus int, expectedBody string, middlewares ...func(*gin.Context), ) { var ( recorder = httptest.NewRecorder() ctx, _ = testrig.CreateGinTestContext(recorder, nil) ) // Prepare the requst body bytes. bodyI, err := ap.Serialize(activity) if err != nil { suite.FailNow(err.Error()) } b, err := json.MarshalIndent(bodyI, "", " ") if err != nil { suite.FailNow(err.Error()) } suite.T().Logf("prepared POST body:\n%s", string(b)) // Prepare signature headers for this Activity. signature, digestHeader, dateHeader := testrig.GetSignatureForActivity( activity, requestingAccount.PublicKeyURI, requestingAccount.PrivateKey, testrig.URLMustParse(targetAccount.InboxURI), ) // Put the request together. ctx.AddParam(users.UsernameKey, targetAccount.Username) ctx.Request = httptest.NewRequest(http.MethodPost, targetAccount.InboxURI, bytes.NewReader(b)) ctx.Request.Header.Set("Signature", signature) ctx.Request.Header.Set("Date", dateHeader) ctx.Request.Header.Set("Digest", digestHeader) ctx.Request.Header.Set("Content-Type", "application/activity+json") // Pass the context through provided middlewares. for _, middleware := range middlewares { middleware(ctx) } // Trigger the function being tested. suite.userModule.InboxPOSTHandler(ctx) // Read the result. result := recorder.Result() defer result.Body.Close() b, err = io.ReadAll(result.Body) if err != nil { suite.FailNow(err.Error()) } errs := gtserror.NewMultiError(2) // Check expected code + body. if resultCode := recorder.Code; expectedHTTPStatus != resultCode { errs.Appendf("expected %d got %d", expectedHTTPStatus, resultCode) } // If we got an expected body, return early. if expectedBody != "" && string(b) != expectedBody { errs.Appendf("expected %s got %s", expectedBody, string(b)) } if err := errs.Combine(); err != nil { suite.FailNow("", "%v (body %s)", err, string(b)) } } func (suite *InboxPostTestSuite) newBlock(blockID string, blockingAccount *gtsmodel.Account, blockedAccount *gtsmodel.Account) vocab.ActivityStreamsBlock { block := streams.NewActivityStreamsBlock() // set the actor property to the block-ing account's URI actorProp := streams.NewActivityStreamsActorProperty() actorIRI := testrig.URLMustParse(blockingAccount.URI) actorProp.AppendIRI(actorIRI) block.SetActivityStreamsActor(actorProp) // set the ID property to the blocks's URI idProp := streams.NewJSONLDIdProperty() idProp.Set(testrig.URLMustParse(blockID)) block.SetJSONLDId(idProp) // set the object property to the target account's URI objectProp := streams.NewActivityStreamsObjectProperty() targetIRI := testrig.URLMustParse(blockedAccount.URI) objectProp.AppendIRI(targetIRI) block.SetActivityStreamsObject(objectProp) // set the TO property to the target account's IRI toProp := streams.NewActivityStreamsToProperty() toIRI := testrig.URLMustParse(blockedAccount.URI) toProp.AppendIRI(toIRI) block.SetActivityStreamsTo(toProp) return block } func (suite *InboxPostTestSuite) newUndo( originalActivity pub.Activity, objectF func() vocab.ActivityStreamsObjectProperty, to string, undoIRI string, ) vocab.ActivityStreamsUndo { undo := streams.NewActivityStreamsUndo() // Set the appropriate actor. undo.SetActivityStreamsActor(originalActivity.GetActivityStreamsActor()) // Set the original activity uri as the 'object' property. undo.SetActivityStreamsObject(objectF()) // Set the To of the undo as the target of the activity. undoTo := streams.NewActivityStreamsToProperty() undoTo.AppendIRI(testrig.URLMustParse(to)) undo.SetActivityStreamsTo(undoTo) // Set the ID property to the undo's URI. undoID := streams.NewJSONLDIdProperty() undoID.SetIRI(testrig.URLMustParse(undoIRI)) undo.SetJSONLDId(undoID) return undo } func (suite *InboxPostTestSuite) newUpdatePerson(person vocab.ActivityStreamsPerson, cc string, updateIRI string) vocab.ActivityStreamsUpdate { // create an update update := streams.NewActivityStreamsUpdate() // set the appropriate actor on it updateActor := streams.NewActivityStreamsActorProperty() updateActor.AppendIRI(person.GetJSONLDId().Get()) update.SetActivityStreamsActor(updateActor) // Set the person as the 'object' property. updateObject := streams.NewActivityStreamsObjectProperty() updateObject.AppendActivityStreamsPerson(person) update.SetActivityStreamsObject(updateObject) // Set the To of the update as public updateTo := streams.NewActivityStreamsToProperty() updateTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) update.SetActivityStreamsTo(updateTo) // set the cc of the update to the receivingAccount updateCC := streams.NewActivityStreamsCcProperty() updateCC.AppendIRI(testrig.URLMustParse(cc)) update.SetActivityStreamsCc(updateCC) // set some random-ass ID for the activity updateID := streams.NewJSONLDIdProperty() updateID.SetIRI(testrig.URLMustParse(updateIRI)) update.SetJSONLDId(updateID) return update } func (suite *InboxPostTestSuite) newDelete(actorIRI string, objectIRI string, deleteIRI string) vocab.ActivityStreamsDelete { // create a delete delete := streams.NewActivityStreamsDelete() // set the appropriate actor on it deleteActor := streams.NewActivityStreamsActorProperty() deleteActor.AppendIRI(testrig.URLMustParse(actorIRI)) delete.SetActivityStreamsActor(deleteActor) // Set 'object' property. deleteObject := streams.NewActivityStreamsObjectProperty() deleteObject.AppendIRI(testrig.URLMustParse(objectIRI)) delete.SetActivityStreamsObject(deleteObject) // Set the To of the delete as public deleteTo := streams.NewActivityStreamsToProperty() deleteTo.AppendIRI(testrig.URLMustParse(pub.PublicActivityPubIRI)) delete.SetActivityStreamsTo(deleteTo) // set some random-ass ID for the activity deleteID := streams.NewJSONLDIdProperty() deleteID.SetIRI(testrig.URLMustParse(deleteIRI)) delete.SetJSONLDId(deleteID) return delete } // TestPostBlock verifies that a remote account can block one of // our instance users. func (suite *InboxPostTestSuite) TestPostBlock() { var ( requestingAccount = suite.testAccounts["remote_account_1"] targetAccount = suite.testAccounts["local_account_1"] activityID = requestingAccount.URI + "/some-new-activity/01FG9C441MCTW3R2W117V2PQK3" ) block := suite.newBlock(activityID, requestingAccount, targetAccount) // Block. suite.inboxPost( block, requestingAccount, targetAccount, http.StatusAccepted, `{"status":"Accepted"}`, suite.signatureCheck, ) // Ensure block created in the database. var ( dbBlock *gtsmodel.Block err error ) if !testrig.WaitFor(func() bool { dbBlock, err = suite.db.GetBlock(context.Background(), requestingAccount.ID, targetAccount.ID) return err == nil && dbBlock != nil }) { suite.FailNow("timed out waiting for block to be created") } } // TestPostUnblock verifies that a remote account who blocks // one of our instance users should be able to undo that block. func (suite *InboxPostTestSuite) TestPostUnblock() { var ( ctx = context.Background() requestingAccount = suite.testAccounts["remote_account_1"] targetAccount = suite.testAccounts["local_account_1"] blockID = "http://fossbros-anonymous.io/blocks/01H1462TPRTVG2RTQCTSQ7N6Q0" undoID = "http://fossbros-anonymous.io/some-activity/01H1463RDQNG5H98F29BXYHW6B" ) // Put a block in the database so we have something to undo. block := >smodel.Block{ ID: id.NewULID(), URI: blockID, AccountID: requestingAccount.ID, TargetAccountID: targetAccount.ID, } if err := suite.db.PutBlock(ctx, block); err != nil { suite.FailNow(err.Error()) } // Create the undo from the AS model block. asBlock, err := suite.tc.BlockToAS(ctx, block) if err != nil { suite.FailNow(err.Error()) } undo := suite.newUndo(asBlock, func() vocab.ActivityStreamsObjectProperty { // Append the whole block as Object. op := streams.NewActivityStreamsObjectProperty() op.AppendActivityStreamsBlock(asBlock) return op }, targetAccount.URI, undoID) // Undo. suite.inboxPost( undo, requestingAccount, targetAccount, http.StatusAccepted, `{"status":"Accepted"}`, suite.signatureCheck, ) // Ensure block removed from the database. if !testrig.WaitFor(func() bool { _, err := suite.db.GetBlockByID(ctx, block.ID) return errors.Is(err, db.ErrNoEntries) }) { suite.FailNow("timed out waiting for block to be removed") } } func (suite *InboxPostTestSuite) TestPostUpdate() { var ( requestingAccount = new(gtsmodel.Account) targetAccount = suite.testAccounts["local_account_1"] activityID = "http://fossbros-anonymous.io/72cc96a3-f742-4daf-b9f5-3407667260c5" updatedDisplayName = "updated display name!" ) // Copy the requesting account, since we'll be changing it. *requestingAccount = *suite.testAccounts["remote_account_1"] // Update the account's display name. requestingAccount.DisplayName = updatedDisplayName // Add an emoji to the account; because we're serializing this // remote account from our own instance, we need to cheat a bit // to get the emoji to work properly, just for this test. testEmoji := >smodel.Emoji{} *testEmoji = *testrig.NewTestEmojis()["yell"] testEmoji.ImageURL = testEmoji.ImageRemoteURL // <- here's the cheat requestingAccount.Emojis = []*gtsmodel.Emoji{testEmoji} // Create an update from the account. asAccount, err := suite.tc.AccountToAS(context.Background(), requestingAccount) if err != nil { suite.FailNow(err.Error()) } update := suite.newUpdatePerson(asAccount, targetAccount.URI, activityID) // Update. suite.inboxPost( update, requestingAccount, targetAccount, http.StatusAccepted, `{"status":"Accepted"}`, suite.signatureCheck, ) // account should be changed in the database now var dbUpdatedAccount *gtsmodel.Account if !testrig.WaitFor(func() bool { // displayName should be updated dbUpdatedAccount, _ = suite.db.GetAccountByID(context.Background(), requestingAccount.ID) return dbUpdatedAccount.DisplayName == updatedDisplayName }) { suite.FailNow("timed out waiting for account update") } // emojis should be updated suite.Contains(dbUpdatedAccount.EmojiIDs, testEmoji.ID) // account should be freshly fetched suite.WithinDuration(time.Now(), dbUpdatedAccount.FetchedAt, 10*time.Second) // everything else should be the same as it was before suite.EqualValues(requestingAccount.Username, dbUpdatedAccount.Username) suite.EqualValues(requestingAccount.Domain, dbUpdatedAccount.Domain) suite.EqualValues(requestingAccount.AvatarMediaAttachmentID, dbUpdatedAccount.AvatarMediaAttachmentID) suite.EqualValues(requestingAccount.AvatarMediaAttachment, dbUpdatedAccount.AvatarMediaAttachment) suite.EqualValues(requestingAccount.AvatarRemoteURL, dbUpdatedAccount.AvatarRemoteURL) suite.EqualValues(requestingAccount.HeaderMediaAttachmentID, dbUpdatedAccount.HeaderMediaAttachmentID) suite.EqualValues(requestingAccount.HeaderMediaAttachment, dbUpdatedAccount.HeaderMediaAttachment) suite.EqualValues(requestingAccount.HeaderRemoteURL, dbUpdatedAccount.HeaderRemoteURL) suite.EqualValues(requestingAccount.Note, dbUpdatedAccount.Note) suite.EqualValues(requestingAccount.Memorial, dbUpdatedAccount.Memorial) suite.EqualValues(requestingAccount.AlsoKnownAsURIs, dbUpdatedAccount.AlsoKnownAsURIs) suite.EqualValues(requestingAccount.MovedToURI, dbUpdatedAccount.MovedToURI) suite.EqualValues(requestingAccount.Bot, dbUpdatedAccount.Bot) suite.EqualValues(requestingAccount.Reason, dbUpdatedAccount.Reason) suite.EqualValues(requestingAccount.Locked, dbUpdatedAccount.Locked) suite.EqualValues(requestingAccount.Discoverable, dbUpdatedAccount.Discoverable) suite.EqualValues(requestingAccount.Privacy, dbUpdatedAccount.Privacy) suite.EqualValues(requestingAccount.Sensitive, dbUpdatedAccount.Sensitive) suite.EqualValues(requestingAccount.Language, dbUpdatedAccount.Language) suite.EqualValues(requestingAccount.URI, dbUpdatedAccount.URI) suite.EqualValues(requestingAccount.URL, dbUpdatedAccount.URL) suite.EqualValues(requestingAccount.InboxURI, dbUpdatedAccount.InboxURI) suite.EqualValues(requestingAccount.OutboxURI, dbUpdatedAccount.OutboxURI) suite.EqualValues(requestingAccount.FollowingURI, dbUpdatedAccount.FollowingURI) suite.EqualValues(requestingAccount.FollowersURI, dbUpdatedAccount.FollowersURI) suite.EqualValues(requestingAccount.FeaturedCollectionURI, dbUpdatedAccount.FeaturedCollectionURI) suite.EqualValues(requestingAccount.ActorType, dbUpdatedAccount.ActorType) suite.EqualValues(requestingAccount.PublicKey, dbUpdatedAccount.PublicKey) suite.EqualValues(requestingAccount.PublicKeyURI, dbUpdatedAccount.PublicKeyURI) suite.EqualValues(requestingAccount.SensitizedAt, dbUpdatedAccount.SensitizedAt) suite.EqualValues(requestingAccount.SilencedAt, dbUpdatedAccount.SilencedAt) suite.EqualValues(requestingAccount.SuspendedAt, dbUpdatedAccount.SuspendedAt) suite.EqualValues(requestingAccount.HideCollections, dbUpdatedAccount.HideCollections) suite.EqualValues(requestingAccount.SuspensionOrigin, dbUpdatedAccount.SuspensionOrigin) } func (suite *InboxPostTestSuite) TestPostDelete() { var ( ctx = context.Background() requestingAccount = suite.testAccounts["remote_account_1"] targetAccount = suite.testAccounts["local_account_1"] activityID = requestingAccount.URI + "/some-new-activity/01FG9C441MCTW3R2W117V2PQK3" ) delete := suite.newDelete(requestingAccount.URI, requestingAccount.URI, activityID) // Delete. suite.inboxPost( delete, requestingAccount, targetAccount, http.StatusAccepted, `{"status":"Accepted"}`, suite.signatureCheck, ) if !testrig.WaitFor(func() bool { // local account 2 blocked foss_satan, that block should be gone now testBlock := suite.testBlocks["local_account_2_block_remote_account_1"] _, err := suite.db.GetBlockByID(ctx, testBlock.ID) return suite.ErrorIs(err, db.ErrNoEntries) }) { suite.FailNow("timed out waiting for block to be removed") } if !testrig.WaitFor(func() bool { // no statuses from foss satan should be left in the database dbStatuses, err := suite.db.GetAccountStatuses(ctx, requestingAccount.ID, 0, false, false, "", "", false, false) return len(dbStatuses) == 0 && errors.Is(err, db.ErrNoEntries) }) { suite.FailNow("timed out waiting for statuses to be removed") } // Account should be stubbified. dbAccount, err := suite.db.GetAccountByID(ctx, requestingAccount.ID) suite.NoError(err) suite.Empty(dbAccount.Note) suite.Empty(dbAccount.DisplayName) suite.Empty(dbAccount.AvatarMediaAttachmentID) suite.Empty(dbAccount.AvatarRemoteURL) suite.Empty(dbAccount.HeaderMediaAttachmentID) suite.Empty(dbAccount.HeaderRemoteURL) suite.Empty(dbAccount.Reason) suite.Empty(dbAccount.Fields) suite.True(*dbAccount.HideCollections) suite.False(*dbAccount.Discoverable) suite.WithinDuration(time.Now(), dbAccount.SuspendedAt, 30*time.Second) suite.Equal(dbAccount.ID, dbAccount.SuspensionOrigin) } func (suite *InboxPostTestSuite) TestPostEmptyCreate() { var ( requestingAccount = suite.testAccounts["remote_account_1"] targetAccount = suite.testAccounts["local_account_1"] ) // Post a create with no object, this // should get accepted and silently dropped // as the lack of ID marks it as transient. create := streams.NewActivityStreamsCreate() suite.inboxPost( create, requestingAccount, targetAccount, http.StatusAccepted, `{"status":"Accepted"}`, suite.signatureCheck, ) } func (suite *InboxPostTestSuite) TestPostCreateMalformedBlock() { var ( blockingAcc = suite.testAccounts["remote_account_1"] blockedAcc = suite.testAccounts["local_account_1"] activityID = blockingAcc.URI + "/some-new-activity/01FG9C441MCTW3R2W117V2PQK3" ) block := streams.NewActivityStreamsBlock() // set the actor property to the block-ing account's URI actorProp := streams.NewActivityStreamsActorProperty() actorIRI := testrig.URLMustParse(blockingAcc.URI) actorProp.AppendIRI(actorIRI) block.SetActivityStreamsActor(actorProp) // set the ID property to the blocks's URI idProp := streams.NewJSONLDIdProperty() idProp.Set(testrig.URLMustParse(activityID)) block.SetJSONLDId(idProp) // set the object property with MISSING block-ed URI. objectProp := streams.NewActivityStreamsObjectProperty() block.SetActivityStreamsObject(objectProp) // set the TO property to the target account's IRI toProp := streams.NewActivityStreamsToProperty() toIRI := testrig.URLMustParse(blockedAcc.URI) toProp.AppendIRI(toIRI) block.SetActivityStreamsTo(toProp) suite.inboxPost( block, blockingAcc, blockedAcc, http.StatusBadRequest, `{"error":"Bad Request: malformed incoming activity"}`, suite.signatureCheck, ) } func (suite *InboxPostTestSuite) TestPostFromBlockedAccount() { var ( requestingAccount = suite.testAccounts["remote_account_1"] targetAccount = suite.testAccounts["local_account_2"] activityID = requestingAccount.URI + "/some-new-activity/01FG9C441MCTW3R2W117V2PQK3" ) person, err := suite.tc.AccountToAS(context.Background(), requestingAccount) if err != nil { suite.FailNow(err.Error()) } // Post an update from foss satan to turtle, who blocks him. update := suite.newUpdatePerson(person, targetAccount.URI, activityID) suite.inboxPost( update, requestingAccount, targetAccount, http.StatusForbidden, `{"error":"Forbidden: blocked"}`, suite.signatureCheck, ) } func (suite *InboxPostTestSuite) TestPostFromBlockedAccountToOtherAccount() { var ( requestingAccount = suite.testAccounts["remote_account_1"] targetAccount = suite.testAccounts["local_account_1"] activity = suite.testActivities["reply_to_turtle_for_turtle"] statusURI = "http://fossbros-anonymous.io/users/foss_satan/statuses/2f1195a6-5cb0-4475-adf5-92ab9a0147fe" ) // Post an reply to turtle to ZORK from remote account. // Turtle blocks the remote account but is only tangentially // related to this POST request. The response will indicate // accepted but the post won't actually be processed. suite.inboxPost( activity.Activity, requestingAccount, targetAccount, http.StatusAccepted, `{"status":"Accepted"}`, suite.signatureCheck, ) _, err := suite.state.DB.GetStatusByURI(context.Background(), statusURI) suite.ErrorIs(err, db.ErrNoEntries) } func (suite *InboxPostTestSuite) TestPostUnauthorized() { var ( requestingAccount = suite.testAccounts["remote_account_1"] targetAccount = suite.testAccounts["local_account_1"] ) // Post an empty create. create := streams.NewActivityStreamsCreate() suite.inboxPost( create, requestingAccount, targetAccount, http.StatusUnauthorized, `{"error":"Unauthorized: not authenticated"}`, // Omit signature check middleware. ) } func TestInboxPostTestSuite(t *testing.T) { suite.Run(t, &InboxPostTestSuite{}) }