From be5e532cd21fdbdfd1589186ed66a495ed5b8b35 Mon Sep 17 00:00:00 2001 From: Vyr Cossont Date: Thu, 4 Jul 2024 19:29:28 -0700 Subject: [PATCH] [bugfix] Handle ErrHideStatus when preparing timeline statuses (#3071) --- internal/processing/timeline/home_test.go | 154 ++++++++++++++++++ internal/processing/timeline/public_test.go | 91 +++++++++++ internal/processing/timeline/timeline_test.go | 2 + internal/timeline/get.go | 13 ++ 4 files changed, 260 insertions(+) create mode 100644 internal/processing/timeline/home_test.go diff --git a/internal/processing/timeline/home_test.go b/internal/processing/timeline/home_test.go new file mode 100644 index 000000000..c73c209a3 --- /dev/null +++ b/internal/processing/timeline/home_test.go @@ -0,0 +1,154 @@ +// 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 timeline_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/filter/visibility" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + tlprocessor "github.com/superseriousbusiness/gotosocial/internal/processing/timeline" + "github.com/superseriousbusiness/gotosocial/internal/timeline" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +type HomeTestSuite struct { + TimelineStandardTestSuite +} + +func (suite *HomeTestSuite) SetupTest() { + suite.TimelineStandardTestSuite.SetupTest() + + suite.state.Timelines.Home = timeline.NewManager( + tlprocessor.HomeTimelineGrab(&suite.state), + tlprocessor.HomeTimelineFilter(&suite.state, visibility.NewFilter(&suite.state)), + tlprocessor.HomeTimelineStatusPrepare(&suite.state, typeutils.NewConverter(&suite.state)), + tlprocessor.SkipInsert(), + ) + if err := suite.state.Timelines.Home.Start(); err != nil { + suite.FailNow(err.Error()) + } +} + +func (suite *HomeTestSuite) TearDownTest() { + if err := suite.state.Timelines.Home.Stop(); err != nil { + suite.FailNow(err.Error()) + } + + suite.TimelineStandardTestSuite.TearDownTest() +} + +// A timeline containing a status hidden due to filtering should return other statuses with no error. +func (suite *HomeTestSuite) TestHomeTimelineGetHideFiltered() { + var ( + ctx = context.Background() + requester = suite.testAccounts["local_account_1"] + authed = &oauth.Auth{Account: requester} + maxID = "" + sinceID = "" + minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus + limit = 40 + local = false + filteredStatus = suite.testStatuses["admin_account_status_2"] + filteredStatusFound = false + filterID = id.NewULID() + filter = >smodel.Filter{ + ID: filterID, + AccountID: requester.ID, + Title: "timeline filtering test", + Action: gtsmodel.FilterActionHide, + Statuses: []*gtsmodel.FilterStatus{ + { + ID: id.NewULID(), + AccountID: requester.ID, + FilterID: filterID, + StatusID: filteredStatus.ID, + }, + }, + ContextHome: util.Ptr(true), + ContextNotifications: util.Ptr(false), + ContextPublic: util.Ptr(false), + ContextThread: util.Ptr(false), + ContextAccount: util.Ptr(false), + } + ) + + // Fetch the timeline to make sure the status we're going to filter is in that section of it. + resp, errWithCode := suite.timeline.HomeTimelineGet( + ctx, + authed, + maxID, + sinceID, + minID, + limit, + local, + ) + suite.NoError(errWithCode) + for _, item := range resp.Items { + if item.(*apimodel.Status).ID == filteredStatus.ID { + filteredStatusFound = true + break + } + } + if !filteredStatusFound { + suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline") + } + // Prune the timeline to drop cached prepared statuses, a side effect of this precondition check. + if _, err := suite.state.Timelines.Home.Prune(ctx, requester.ID, 0, 0); err != nil { + suite.FailNow(err.Error()) + } + + // Create a filter to hide one status on the timeline. + if err := suite.db.PutFilter(ctx, filter); err != nil { + suite.FailNow(err.Error()) + } + + // Fetch the timeline again with the filter in place. + resp, errWithCode = suite.timeline.HomeTimelineGet( + ctx, + authed, + maxID, + sinceID, + minID, + limit, + local, + ) + + // We should have some statuses even though one status was filtered out. + suite.NoError(errWithCode) + suite.NotEmpty(resp.Items) + // The filtered status should not be there. + filteredStatusFound = false + for _, item := range resp.Items { + if item.(*apimodel.Status).ID == filteredStatus.ID { + filteredStatusFound = true + break + } + } + suite.False(filteredStatusFound) +} + +func TestHomeTestSuite(t *testing.T) { + suite.Run(t, new(HomeTestSuite)) +} diff --git a/internal/processing/timeline/public_test.go b/internal/processing/timeline/public_test.go index f14fee1b9..6b01c9849 100644 --- a/internal/processing/timeline/public_test.go +++ b/internal/processing/timeline/public_test.go @@ -22,6 +22,10 @@ import ( "testing" "github.com/stretchr/testify/suite" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/util" ) type PublicTestSuite struct { @@ -91,6 +95,93 @@ func (suite *PublicTestSuite) TestPublicTimelineGetNotEmpty() { suite.Equal(`http://localhost:8080/api/v1/timelines/public?limit=1&min_id=01HE7XJ1CG84TBKH5V9XKBVGF5&local=false`, resp.PrevLink) } +// A timeline containing a status hidden due to filtering should return other statuses with no error. +func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { + var ( + ctx = context.Background() + requester = suite.testAccounts["local_account_1"] + maxID = "" + sinceID = "" + minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus + limit = 10 + local = false + filteredStatus = suite.testStatuses["admin_account_status_2"] + filteredStatusFound = false + filterID = id.NewULID() + filter = >smodel.Filter{ + ID: filterID, + AccountID: requester.ID, + Title: "timeline filtering test", + Action: gtsmodel.FilterActionHide, + Statuses: []*gtsmodel.FilterStatus{ + { + ID: id.NewULID(), + AccountID: requester.ID, + FilterID: filterID, + StatusID: filteredStatus.ID, + }, + }, + ContextHome: util.Ptr(false), + ContextNotifications: util.Ptr(false), + ContextPublic: util.Ptr(true), + ContextThread: util.Ptr(false), + ContextAccount: util.Ptr(false), + } + ) + + // Fetch the timeline to make sure the status we're going to filter is in that section of it. + resp, errWithCode := suite.timeline.PublicTimelineGet( + ctx, + requester, + maxID, + sinceID, + minID, + limit, + local, + ) + suite.NoError(errWithCode) + for _, item := range resp.Items { + if item.(*apimodel.Status).ID == filteredStatus.ID { + filteredStatusFound = true + break + } + } + if !filteredStatusFound { + suite.FailNow("precondition failed: status we would filter isn't present in unfiltered timeline") + } + // The public timeline has no prepared status cache and doesn't need to be pruned, + // as in the home timeline version of this test. + + // Create a filter to hide one status on the timeline. + if err := suite.db.PutFilter(ctx, filter); err != nil { + suite.FailNow(err.Error()) + } + + // Fetch the timeline again with the filter in place. + resp, errWithCode = suite.timeline.PublicTimelineGet( + ctx, + requester, + maxID, + sinceID, + minID, + limit, + local, + ) + + // We should have some statuses even though one status was filtered out. + suite.NoError(errWithCode) + suite.NotEmpty(resp.Items) + // The filtered status should not be there. + filteredStatusFound = false + for _, item := range resp.Items { + if item.(*apimodel.Status).ID == filteredStatus.ID { + filteredStatusFound = true + break + } + } + suite.False(filteredStatusFound) +} + func TestPublicTestSuite(t *testing.T) { suite.Run(t, new(PublicTestSuite)) } diff --git a/internal/processing/timeline/timeline_test.go b/internal/processing/timeline/timeline_test.go index 79626830e..593bfb8f3 100644 --- a/internal/processing/timeline/timeline_test.go +++ b/internal/processing/timeline/timeline_test.go @@ -35,6 +35,7 @@ type TimelineStandardTestSuite struct { // standard suite models testAccounts map[string]*gtsmodel.Account + testStatuses map[string]*gtsmodel.Status // module being tested timeline timeline.Processor @@ -42,6 +43,7 @@ type TimelineStandardTestSuite struct { func (suite *TimelineStandardTestSuite) SetupSuite() { suite.testAccounts = testrig.NewTestAccounts() + suite.testStatuses = testrig.NewTestStatuses() } func (suite *TimelineStandardTestSuite) SetupTest() { diff --git a/internal/timeline/get.go b/internal/timeline/get.go index 93c869e73..06ee8c174 100644 --- a/internal/timeline/get.go +++ b/internal/timeline/get.go @@ -25,6 +25,7 @@ import ( "codeberg.org/gruf/go-kv" "github.com/superseriousbusiness/gotosocial/internal/db" + statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/log" @@ -246,6 +247,12 @@ func (t *timeline) getXBetweenIDs(ctx context.Context, amount int, behindID stri // race condition? That's OK, we can do it now. prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID) if err != nil { + if errors.Is(err, statusfilter.ErrHideStatus) { + // This item has been filtered out by the requesting user's filters. + // Remove it and skip past it. + removeElements = append(removeElements, e) + return true, nil + } if errors.Is(err, db.ErrNoEntries) { // ErrNoEntries means something has been deleted, // so we'll likely not be able to ever prepare this. @@ -340,6 +347,12 @@ func (t *timeline) getXBetweenIDs(ctx context.Context, amount int, behindID stri // race condition? That's OK, we can do it now. prepared, err := t.prepareFunction(ctx, t.timelineID, entry.itemID) if err != nil { + if errors.Is(err, statusfilter.ErrHideStatus) { + // This item has been filtered out by the requesting user's filters. + // Remove it and skip past it. + removeElements = append(removeElements, e) + continue + } if errors.Is(err, db.ErrNoEntries) { // ErrNoEntries means something has been deleted, // so we'll likely not be able to ever prepare this.