From e924be379f79a3acb83d5966eaf424439fc1da80 Mon Sep 17 00:00:00 2001 From: Leigh MacDonald Date: Sat, 22 Jun 2024 02:57:56 -0600 Subject: [PATCH] Add wordfilter tests --- Makefile | 6 +- frontend/src/api/filters.ts | 2 +- internal/domain/word_filters.go | 36 ++++++- internal/test/main_test.go | 4 + internal/test/report_test.go | 8 +- internal/test/wordfilter_test.go | 109 ++++++++++++++++++++++ internal/wordfilter/word_filters.go | 12 +-- internal/wordfilter/wordfilter_service.go | 62 ++++-------- internal/wordfilter/wordfilter_usecase.go | 20 ++-- 9 files changed, 191 insertions(+), 68 deletions(-) create mode 100644 internal/test/wordfilter_test.go diff --git a/Makefile b/Makefile index 4a5ea85ca..59be8e4cc 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,11 @@ test-ts: @cd frontend && pnpm run test test-go: - @go test $(GO_FLAGS) -race -cover . ./... + @go test $(GO_FLAGS) -race ./... + +test-go-cover: + @go test $(GO_FLAGS) -race -coverprofile coverage.out ./... + @go tool cover -html=coverage.out install_deps: go install github.com/daixiang0/gci@v0.13.4 diff --git a/frontend/src/api/filters.ts b/frontend/src/api/filters.ts index e719277f1..7010fdf75 100644 --- a/frontend/src/api/filters.ts +++ b/frontend/src/api/filters.ts @@ -35,7 +35,7 @@ export interface Filter { } export const apiGetFilters = async (abortController?: AbortController) => - await apiCall(`/api/filters/query`, 'POST', abortController); + await apiCall(`/api/filters`, 'GET', abortController); export const apiCreateFilter = async (filter: Filter) => await apiCall(`/api/filters`, 'POST', filter); diff --git a/internal/domain/word_filters.go b/internal/domain/word_filters.go index 7b0f95dbe..79be378eb 100644 --- a/internal/domain/word_filters.go +++ b/internal/domain/word_filters.go @@ -2,6 +2,7 @@ package domain import ( "context" + "errors" "regexp" "strings" "time" @@ -20,7 +21,7 @@ type WordFilterRepository interface { type WordFilterUsecase interface { Edit(ctx context.Context, user PersonInfo, filterID int64, filter Filter) (Filter, error) Create(ctx context.Context, user PersonInfo, filter Filter) (Filter, error) - DropFilter(ctx context.Context, filter Filter) error + DropFilter(ctx context.Context, filterID int64) error GetFilterByID(ctx context.Context, filterID int64) (Filter, error) GetFilters(ctx context.Context) ([]Filter, error) Check(query string) []Filter @@ -28,6 +29,10 @@ type WordFilterUsecase interface { AddMessageFilterMatch(ctx context.Context, messageID int64, filterID int64) error } +type RequestQuery struct { + Query string +} + type FilterAction int const ( @@ -36,6 +41,35 @@ const ( Ban ) +func NewFilter(author steamid.SteamID, pattern string, regex bool, action FilterAction, duration string, weight int) (Filter, error) { + now := time.Now() + + filter := Filter{ + AuthorID: author, + Pattern: pattern, + IsRegex: regex, + IsEnabled: true, + Action: action, + Duration: duration, + Regex: nil, + TriggerCount: 0, + Weight: weight, + CreatedOn: now, + UpdatedOn: now, + } + + if regex { + compiled, errRegex := regexp.Compile(pattern) + if errRegex != nil { + return Filter{}, errors.Join(errRegex, ErrInvalidRegex) + } + + filter.Regex = compiled + } + + return filter, nil +} + type Filter struct { FilterID int64 `json:"filter_id"` AuthorID steamid.SteamID `json:"author_id"` diff --git a/internal/test/main_test.go b/internal/test/main_test.go index 514f22c46..99da1ebd8 100644 --- a/internal/test/main_test.go +++ b/internal/test/main_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net" "net/http" "net/http/httptest" @@ -81,6 +82,8 @@ var ( ) func TestMain(m *testing.M) { + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) + testCtx, cancel := context.WithTimeout(context.Background(), time.Minute*2) defer cancel() @@ -196,6 +199,7 @@ func testRouter() *gin.Engine { config.NewConfigHandler(router, configUC, authUC, app.Version()) report.NewReportHandler(router, reportUC, authUC) appeal.NewAppealHandler(router, appealUC, banSteamUC, configUC, personUC, discordUC, authUC) + wordfilter.NewWordFilterHandler(router, configUC, wordFilterUC, chatUC, authUC) return router } diff --git a/internal/test/report_test.go b/internal/test/report_test.go index 9bdf34fa8..1defca4fb 100644 --- a/internal/test/report_test.go +++ b/internal/test/report_test.go @@ -140,16 +140,10 @@ func TestReportPermissions(t *testing.T) { }, { path: "/api/reports/user", - method: http.MethodPost, + method: http.MethodGet, code: http.StatusForbidden, levels: authed, }, - { - path: "/api/report/1/state", - method: http.MethodPost, - code: http.StatusForbidden, - levels: moderators, - }, { path: "/api/reports", method: http.MethodPost, diff --git a/internal/test/wordfilter_test.go b/internal/test/wordfilter_test.go new file mode 100644 index 000000000..9c9648e6f --- /dev/null +++ b/internal/test/wordfilter_test.go @@ -0,0 +1,109 @@ +package test_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/leighmacdonald/gbans/internal/domain" + "github.com/stretchr/testify/require" +) + +func TestWordFilter(t *testing.T) { + router := testRouter() + moderator := getModerator() + creds := loginUser(moderator) + + // Shouldn't be filters already + var filters []domain.Filter + testEndpointWithReceiver(t, router, http.MethodGet, "/api/filters", nil, http.StatusOK, creds, &filters) + require.Empty(t, filters) + + // Create a filter + req, errReq := domain.NewFilter(moderator.SteamID, "test", true, domain.Mute, "1d", 1) + require.NoError(t, errReq) + + var created domain.Filter + testEndpointWithReceiver(t, router, http.MethodPost, "/api/filters", req, http.StatusOK, creds, &created) + require.Positive(t, created.FilterID) + + // Check it was added + testEndpointWithReceiver(t, router, http.MethodGet, "/api/filters", req, http.StatusOK, creds, &filters) + require.NotEmpty(t, filters) + + // Edit it + edit := filters[0] + edit.Pattern = "blah" + edit.IsRegex = false + + var edited domain.Filter + testEndpointWithReceiver(t, router, http.MethodPost, fmt.Sprintf("/api/filters/%d", edit.FilterID), edit, http.StatusOK, creds, &edited) + require.Equal(t, edit.FilterID, edited.FilterID) + require.Equal(t, edit.AuthorID, edited.AuthorID) + require.Equal(t, edit.Pattern, edited.Pattern) + require.Equal(t, edit.IsRegex, edited.IsRegex) + require.Equal(t, edit.IsEnabled, edited.IsEnabled) + require.Equal(t, edit.Action, edited.Action) + require.Equal(t, edit.Duration, edited.Duration) + require.Equal(t, edit.TriggerCount, edited.TriggerCount) + require.Equal(t, edit.Weight, edited.Weight) + require.NotEqual(t, edit.UpdatedOn, edited.UpdatedOn) + + // Match it + var matched []domain.Filter + testEndpointWithReceiver(t, router, http.MethodPost, "/api/filter_match", domain.RequestQuery{Query: edited.Pattern}, http.StatusOK, creds, &matched) + require.NotEmpty(t, matched) + require.Equal(t, matched[0].FilterID, edited.FilterID) + + // Delete it + testEndpoint(t, router, http.MethodDelete, fmt.Sprintf("/api/filters/%d", edit.FilterID), req, http.StatusOK, creds) + + // Shouldn't match now + testEndpointWithReceiver(t, router, http.MethodPost, "/api/filter_match", domain.RequestQuery{Query: edited.Pattern}, http.StatusOK, creds, &matched) + require.Empty(t, matched) + + // Make sure it was deleted + testEndpointWithReceiver(t, router, http.MethodGet, "/api/filters", nil, http.StatusOK, creds, &filters) + require.Empty(t, filters) +} + +func TestWordFilterPermissions(t *testing.T) { + testPermissions(t, testRouter(), []permTestValues{ + { + path: "/api/filters/query", + method: http.MethodPost, + code: http.StatusForbidden, + levels: moderators, + }, + { + path: "/api/filters/state", + method: http.MethodGet, + code: http.StatusForbidden, + levels: moderators, + }, + { + path: "/api/filters", + method: http.MethodPost, + code: http.StatusForbidden, + levels: moderators, + }, + { + path: "/api/filters/1", + method: http.MethodPost, + code: http.StatusForbidden, + levels: moderators, + }, + { + path: "/api/filters/1", + method: http.MethodDelete, + code: http.StatusForbidden, + levels: moderators, + }, + { + path: "/api/filter_match", + method: http.MethodPost, + code: http.StatusForbidden, + levels: moderators, + }, + }) +} diff --git a/internal/wordfilter/word_filters.go b/internal/wordfilter/word_filters.go index 0dca67ae1..2d62b741b 100644 --- a/internal/wordfilter/word_filters.go +++ b/internal/wordfilter/word_filters.go @@ -26,17 +26,17 @@ func (f *WordFilters) Import(filters []domain.Filter) { f.wordFilters = filters } -func (f *WordFilters) Add(filter *domain.Filter) { +func (f *WordFilters) Add(filter domain.Filter) { f.Lock() - f.wordFilters = append(f.wordFilters, *filter) + f.wordFilters = append(f.wordFilters, filter) f.Unlock() } // Match checks to see if the body of text contains a known filtered word // It will only return the first matched filter found. -func (f *WordFilters) Match(body string) (string, *domain.Filter) { +func (f *WordFilters) Match(body string) (string, domain.Filter, bool) { if body == "" { - return "", nil + return "", domain.Filter{}, false } words := strings.Split(strings.ToLower(body), " ") @@ -47,12 +47,12 @@ func (f *WordFilters) Match(body string) (string, *domain.Filter) { for _, filter := range f.wordFilters { for _, word := range words { if filter.IsEnabled && filter.Match(word) { - return word, &filter + return word, filter, true } } } - return "", nil + return "", domain.Filter{}, false } func (f *WordFilters) Remove(filterID int64) { diff --git a/internal/wordfilter/wordfilter_service.go b/internal/wordfilter/wordfilter_service.go index 7d18b2558..70ae95459 100644 --- a/internal/wordfilter/wordfilter_service.go +++ b/internal/wordfilter/wordfilter_service.go @@ -1,7 +1,6 @@ package wordfilter import ( - "errors" "log/slog" "net/http" @@ -28,7 +27,7 @@ func NewWordFilterHandler(engine *gin.Engine, config domain.ConfigUsecase, wordF modGroup := engine.Group("/") { mod := modGroup.Use(auth.AuthMiddleware(domain.PModerator)) - mod.POST("/api/filters/query", handler.queryFilters()) + mod.GET("/api/filters", handler.queryFilters()) mod.GET("/api/filters/state", handler.filterStates()) mod.POST("/api/filters", handler.createFilter()) mod.POST("/api/filters/:filter_id", handler.editFilter()) @@ -47,6 +46,10 @@ func (h *wordFilterHandler) queryFilters() gin.HandlerFunc { return } + if words == nil { + words = []domain.Filter{} + } + ctx.JSON(http.StatusOK, words) } } @@ -75,6 +78,7 @@ func (h *wordFilterHandler) editFilter() gin.HandlerFunc { } ctx.JSON(http.StatusOK, wordFilter) + slog.Info("Filter updated", slog.Int64("filter_id", wordFilter.FilterID)) } } @@ -94,6 +98,7 @@ func (h *wordFilterHandler) createFilter() gin.HandlerFunc { } ctx.JSON(http.StatusOK, wordFilter) + slog.Info("Created filter", slog.Int64("filter_id", wordFilter.FilterID)) } } @@ -101,65 +106,35 @@ func (h *wordFilterHandler) deleteFilter() gin.HandlerFunc { return func(ctx *gin.Context) { filterID, filterIDErr := httphelper.GetInt64Param(ctx, "filter_id") if filterIDErr != nil { - httphelper.ResponseErr(ctx, http.StatusBadRequest, domain.ErrInvalidParameter) - slog.Warn("Failed to get filter_id", log.ErrAttr(filterIDErr)) - - return - } - - filter, errGet := h.filters.GetFilterByID(ctx, filterID) - if errGet != nil { - if errors.Is(errGet, domain.ErrNoResult) { - httphelper.HandleErrNotFound(ctx) - - return - } - - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to get filter", log.ErrAttr(errGet)) + httphelper.HandleErrs(ctx, filterIDErr) return } - if errDrop := h.filters.DropFilter(ctx, filter); errDrop != nil { - httphelper.HandleErrInternal(ctx) + if errDrop := h.filters.DropFilter(ctx, filterID); errDrop != nil { + httphelper.HandleErrs(ctx, domain.ErrFailedFetchBan) slog.Error("Failed to drop filter", log.ErrAttr(errDrop)) return } ctx.JSON(http.StatusOK, gin.H{}) + slog.Info("Deleted filter", slog.Int64("filter_id", filterID)) } } func (h *wordFilterHandler) checkFilter() gin.HandlerFunc { - type matchRequest struct { - Query string - } - return func(ctx *gin.Context) { - var req matchRequest + var req domain.RequestQuery if !httphelper.Bind(ctx, &req) { return } - words, errGetFilters := h.filters.GetFilters(ctx) - if errGetFilters != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to get filters", log.ErrAttr(errGetFilters)) - - return + if matches := h.filters.Check(req.Query); matches == nil { + ctx.JSON(http.StatusOK, []domain.Filter{}) + } else { + ctx.JSON(http.StatusOK, matches) } - - var matches []domain.Filter - - for _, filter := range words { - if filter.Match(req.Query) { - matches = append(matches, filter) - } - } - - ctx.JSON(http.StatusOK, matches) } } @@ -169,12 +144,9 @@ func (h *wordFilterHandler) filterStates() gin.HandlerFunc { Current []domain.UserWarning `json:"current"` } - maxWeight := h.config.Config().Filters.MaxWeight - return func(ctx *gin.Context) { state := h.chat.WarningState() - - outputState := warningState{MaxWeight: maxWeight} + outputState := warningState{MaxWeight: h.config.Config().Filters.MaxWeight} for _, warn := range state { outputState.Current = append(outputState.Current, warn...) diff --git a/internal/wordfilter/wordfilter_usecase.go b/internal/wordfilter/wordfilter_usecase.go index 9f275ac8a..e75e71e4a 100644 --- a/internal/wordfilter/wordfilter_usecase.go +++ b/internal/wordfilter/wordfilter_usecase.go @@ -58,6 +58,9 @@ func (w *wordFilterUsecase) Edit(ctx context.Context, user domain.PersonInfo, fi slog.Info("Edited filter", slog.Int64("filter_id", filterID)) + w.wordFilters.Remove(filterID) + w.wordFilters.Add(existingFilter) + return existingFilter, nil } @@ -106,23 +109,26 @@ func (w *wordFilterUsecase) Create(ctx context.Context, user domain.PersonInfo, newFilter.Init() - w.wordFilters.Add(&newFilter) - - slog.Info("Created filter", slog.Int64("filter_id", newFilter.FilterID)) + w.wordFilters.Add(newFilter) - w.discord.SendPayload(domain.ChannelWordFilterLog, discord.FilterAddMessage(newFilter)) + go w.discord.SendPayload(domain.ChannelWordFilterLog, discord.FilterAddMessage(newFilter)) return newFilter, nil } -func (w *wordFilterUsecase) DropFilter(ctx context.Context, filter domain.Filter) error { +func (w *wordFilterUsecase) DropFilter(ctx context.Context, filterID int64) error { + filter, errGet := w.GetFilterByID(ctx, filterID) + if errGet != nil { + return errGet + } + if err := w.repository.DropFilter(ctx, filter); err != nil { return err } - slog.Info("Deleted filter", slog.Int64("id", filter.FilterID)) + w.wordFilters.Remove(filterID) - w.discord.SendPayload(domain.ChannelWordFilterLog, discord.FilterDelMessage(filter)) + go w.discord.SendPayload(domain.ChannelWordFilterLog, discord.FilterDelMessage(filter)) return nil }