From c51029b40a028acfd1da0665b97a9b128d29fdd0 Mon Sep 17 00:00:00 2001 From: Leigh MacDonald Date: Sat, 22 Jun 2024 01:41:08 -0600 Subject: [PATCH] Add report tests --- frontend/src/api/report.ts | 9 +- internal/ban/ban_steam_usecase.go | 20 +- internal/cmd/serve.go | 2 +- internal/discord/responses.go | 25 +- internal/domain/report.go | 18 +- internal/domain/srcds.go | 2 +- internal/report/report_service.go | 369 ++++-------------------------- internal/report/report_usecase.go | 221 +++++++++++++++++- internal/srcds/srcds_service.go | 5 +- internal/srcds/srcds_usecase.go | 50 ++-- internal/test/config_test.go | 2 +- internal/test/helpers_test.go | 8 +- internal/test/main_test.go | 18 +- internal/test/report_test.go | 160 +++++++++++++ 14 files changed, 494 insertions(+), 415 deletions(-) create mode 100644 internal/test/report_test.go diff --git a/frontend/src/api/report.ts b/frontend/src/api/report.ts index d31dd449..bcf70c01 100644 --- a/frontend/src/api/report.ts +++ b/frontend/src/api/report.ts @@ -118,7 +118,12 @@ export const apiGetReports = async (opts?: ReportQueryFilter, abortController?: }; export const apiGetUserReports = async (abortController?: AbortController) => { - const resp = await apiCall(`/api/reports/user`, 'POST', {}, abortController); + const resp = await apiCall( + `/api/reports/user`, + 'GET', + undefined, + abortController + ); return resp.map(transformTimeStampedDates); }; @@ -133,7 +138,7 @@ export const apiGetReportMessages = async (report_id: number, abortController?: ); export interface CreateReportMessage { - message: string; + body_md: string; } export const apiCreateReportMessage = async (report_id: number, message: string) => diff --git a/internal/ban/ban_steam_usecase.go b/internal/ban/ban_steam_usecase.go index fc439f6c..22604fd0 100644 --- a/internal/ban/ban_steam_usecase.go +++ b/internal/ban/ban_steam_usecase.go @@ -150,26 +150,12 @@ func (s banSteamUsecase) Ban(ctx context.Context, curUser domain.PersonInfo, ori return ban, errors.Join(errBannedPerson, domain.ErrSaveBan) } - s.discord.SendPayload(domain.ChannelBanLog, discord.BanSteamResponse(bannedPerson)) - - updateAppealState := func(reportId int64) error { - report, errReport := s.reports.GetReport(ctx, curUser, reportId) - if errReport != nil { - return errors.Join(errReport, domain.ErrGetBanReport) - } - - report.ReportStatus = domain.ClosedWithAction - if errSaveReport := s.reports.SaveReport(ctx, &report.Report); errSaveReport != nil { - return errors.Join(errSaveReport, domain.ErrReportStateUpdate) - } - - return nil - } + go s.discord.SendPayload(domain.ChannelBanLog, discord.BanSteamResponse(bannedPerson)) // Close the report if the ban was attached to one if banSteam.ReportID > 0 { - if errRep := updateAppealState(banSteam.ReportID); errRep != nil { - return ban, errRep + if _, errSaveReport := s.reports.SetReportStatus(ctx, banSteam.ReportID, curUser, domain.ClosedWithAction); errSaveReport != nil { + return ban, errors.Join(errSaveReport, domain.ErrReportStateUpdate) } } diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index 4995337c..062ee474 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -310,7 +310,7 @@ func serveCmd() *cobra.Command { //nolint:maintidx notification.NewNotificationHandler(router, notificationUsecase, authUsecase) patreon.NewPatreonHandler(router, patreonUsecase, authUsecase, configUsecase) person.NewPersonHandler(router, configUsecase, personUsecase, authUsecase) - report.NewReportHandler(router, reportUsecase, configUsecase, discordUsecase, personUsecase, authUsecase, demoUsecase) + report.NewReportHandler(router, reportUsecase, authUsecase) servers.NewServerHandler(router, serversUsecase, stateUsecase, authUsecase, personUsecase) srcds.NewSRCDSHandler(router, srcdsUsecase, serversUsecase, personUsecase, assetUsecase, reportUsecase, banUsecase, networkUsecase, banGroupUsecase, demoUsecase, authUsecase, banASNUsecase, banNetUsecase, diff --git a/internal/discord/responses.go b/internal/discord/responses.go index 07446c1e..d4eae9be 100644 --- a/internal/discord/responses.go +++ b/internal/discord/responses.go @@ -307,7 +307,7 @@ func DeleteReportMessage(existing domain.ReportMessage, user domain.PersonInfo, return msgEmbed.AddAuthorPersonInfo(user, userURL).Embed().Truncate().MessageEmbed } -func NewInGameReportResponse(report domain.Report, reportURL string, author domain.PersonInfo, authorURL string, _ string) *discordgo.MessageEmbed { +func NewInGameReportResponse(report domain.ReportWithAuthor, reportURL string, author domain.PersonInfo, authorURL string, _ string) *discordgo.MessageEmbed { msgEmbed := NewEmbed("New User Report Created") msgEmbed. Embed(). @@ -1149,15 +1149,6 @@ func BanIPMessage() *discordgo.MessageEmbed { MessageEmbed } -func SetSteamMessage() *discordgo.MessageEmbed { - return NewEmbed().Embed(). - SetTitle("Steam Account Linked"). - SetDescription("Your steam and discord accounts are now linked"). - SetColor(ColourSuccess). - Truncate(). - MessageEmbed -} - func NotificationMessage(message string, link string) *discordgo.MessageEmbed { msgEmbed := NewEmbed("Notification", message) if link != "" { @@ -1167,6 +1158,20 @@ func NotificationMessage(message string, link string) *discordgo.MessageEmbed { return msgEmbed.Embed().Truncate().MessageEmbed } +func ReportStatusChangeMessage(report domain.ReportWithAuthor, fromStatus domain.ReportStatus, link string) *discordgo.MessageEmbed { + msgEmbed := NewEmbed( + "Report status changed", + fmt.Sprintf("Changed from %s to %s", fromStatus.String(), report.ReportStatus.String()), + link) + + msgEmbed.Embed(). + AddField("report_id", strconv.FormatInt(report.ReportID, 10)) + msgEmbed.AddAuthorPersonInfo(report.Author, link) + msgEmbed.AddTargetPerson(report.Subject) + + return msgEmbed.Embed().Truncate().MessageEmbed +} + func VoteResultMessage(conf domain.Config, result domain.VoteResult, source domain.Person, target domain.Person) *discordgo.MessageEmbed { avatarSource := domain.NewAvatarLinks(source.AvatarHash) avatarTarget := domain.NewAvatarLinks(target.AvatarHash) diff --git a/internal/domain/report.go b/internal/domain/report.go index fe22468d..045882e1 100644 --- a/internal/domain/report.go +++ b/internal/domain/report.go @@ -26,14 +26,24 @@ type ReportUsecase interface { GetReport(ctx context.Context, curUser PersonInfo, reportID int64) (ReportWithAuthor, error) GetReportMessages(ctx context.Context, reportID int64) ([]ReportMessage, error) GetReportMessageByID(ctx context.Context, reportMessageID int64) (ReportMessage, error) - DropReportMessage(ctx context.Context, message *ReportMessage) error + DropReportMessage(ctx context.Context, curUser PersonInfo, reportMessageID int64) error DropReport(ctx context.Context, report *Report) error - SaveReport(ctx context.Context, report *Report) error - SaveReportMessage(ctx context.Context, message *ReportMessage) error + SaveReport(ctx context.Context, currentUser UserProfile, req RequestReportCreate) (ReportWithAuthor, error) + CreateReportMessage(ctx context.Context, reportID int64, curUser PersonInfo, req RequestMessageBodyMD) (ReportMessage, error) + EditReportMessage(ctx context.Context, reportMessageID int64, curUser PersonInfo, req RequestMessageBodyMD) (ReportMessage, error) GetReportBySteamID(ctx context.Context, authorID steamid.SteamID, steamID steamid.SteamID) (Report, error) + SetReportStatus(ctx context.Context, reportID int64, user PersonInfo, status ReportStatus) (ReportWithAuthor, error) +} + +type RequestMessageBodyMD struct { + BodyMD string `json:"body_md"` +} + +type RequestReportStatusUpdate struct { + Status ReportStatus `json:"status"` } -type CreateReportReq struct { +type RequestReportCreate struct { SourceID steamid.SteamID `json:"source_id"` TargetID steamid.SteamID `json:"target_id"` Description string `json:"description"` diff --git a/internal/domain/srcds.go b/internal/domain/srcds.go index ed860764..c8acba94 100644 --- a/internal/domain/srcds.go +++ b/internal/domain/srcds.go @@ -55,7 +55,7 @@ type SRCDSRepository interface { //nolint:interfacebloat type SRCDSUsecase interface { //nolint:interfacebloat GetBanState(ctx context.Context, steamID steamid.SteamID, ip netip.Addr) (PlayerBanState, string, error) - Report(ctx context.Context, currentUser UserProfile, req CreateReportReq) (*Report, error) + Report(ctx context.Context, currentUser UserProfile, req RequestReportCreate) (ReportWithAuthor, error) GetAdminByID(ctx context.Context, adminID int) (SMAdmin, error) AddAdmin(ctx context.Context, alias string, authType AuthType, identity string, flags string, immunity int, password string) (SMAdmin, error) DelAdmin(ctx context.Context, adminID int) error diff --git a/internal/report/report_service.go b/internal/report/report_service.go index 2b046b44..987e50e3 100644 --- a/internal/report/report_service.go +++ b/internal/report/report_service.go @@ -1,200 +1,63 @@ package report import ( - "context" - "errors" - "fmt" "log/slog" "net/http" - "time" "github.com/gin-gonic/gin" - "github.com/leighmacdonald/gbans/internal/discord" "github.com/leighmacdonald/gbans/internal/domain" "github.com/leighmacdonald/gbans/internal/httphelper" - "github.com/leighmacdonald/gbans/internal/thirdparty" "github.com/leighmacdonald/gbans/pkg/log" "github.com/leighmacdonald/steamid/v4/steamid" ) type reportHandler struct { reports domain.ReportUsecase - config domain.ConfigUsecase - discord domain.DiscordUsecase - persons domain.PersonUsecase - demos domain.DemoUsecase } -func NewReportHandler(engine *gin.Engine, reports domain.ReportUsecase, config domain.ConfigUsecase, - discord domain.DiscordUsecase, person domain.PersonUsecase, auth domain.AuthUsecase, demos domain.DemoUsecase, +func NewReportHandler(engine *gin.Engine, reports domain.ReportUsecase, auth domain.AuthUsecase, ) { handler := reportHandler{ reports: reports, - config: config, - discord: discord, - persons: person, - demos: demos, } // auth authedGrp := engine.Group("/") { authed := authedGrp.Use(auth.AuthMiddleware(domain.PUser)) + + // Reports authed.POST("/api/report", handler.onAPIPostReportCreate()) authed.GET("/api/report/:report_id", handler.onAPIGetReport()) authed.POST("/api/report_status/:report_id", handler.onAPISetReportStatus()) + authed.GET("/api/reports/user", handler.onAPIGetUserReports()) + + // Replies authed.GET("/api/report/:report_id/messages", handler.onAPIGetReportMessages()) authed.POST("/api/report/:report_id/messages", handler.onAPIPostReportMessage()) authed.POST("/api/report/message/:report_message_id", handler.onAPIEditReportMessage()) authed.DELETE("/api/report/message/:report_message_id", handler.onAPIDeleteReportMessage()) - authed.POST("/api/reports/user", handler.onAPIGetUserReports()) } // mod modGrp := engine.Group("/") { mod := modGrp.Use(auth.AuthMiddleware(domain.PModerator)) - mod.POST("/api/report/:report_id/state", handler.onAPIPostBanState()) mod.POST("/api/reports", handler.onAPIGetAllReports()) } } -func (h reportHandler) onAPIPostBanState() gin.HandlerFunc { - // TODO doesnt do anything - return func(ctx *gin.Context) { - reportID, errID := httphelper.GetInt64Param(ctx, "report_id") - if errID != nil || reportID <= 0 { - httphelper.HandleErrBadRequest(ctx) - if errID != nil { - slog.Warn("Failed to get report_id", log.ErrAttr(errID)) - } - - return - } - - report, errReport := h.reports.GetReport(ctx, httphelper.CurrentUserProfile(ctx), reportID) - if errReport != nil { - httphelper.HandleErrs(ctx, errReport) - slog.Error("Failed to get user report", log.ErrAttr(errReport)) - - return - } - - ctx.JSON(http.StatusOK, report) - - h.discord.SendPayload(domain.ChannelModAppealLog, discord.EditBanAppealStatusMessage()) - } -} - func (h reportHandler) onAPIPostReportCreate() gin.HandlerFunc { return func(ctx *gin.Context) { currentUser := httphelper.CurrentUserProfile(ctx) - var req domain.CreateReportReq + var req domain.RequestReportCreate if !httphelper.Bind(ctx, &req) { return } - if req.Description == "" || len(req.Description) < 10 { - httphelper.ResponseErr(ctx, http.StatusBadRequest, fmt.Errorf("%w: description", domain.ErrParamInvalid)) - - return - } - - // ServerStore initiated requests will have a sourceID set by the server - // Web based reports the source should not be set, the reporter will be taken from the - // current session information instead - if !req.SourceID.Valid() { - req.SourceID = currentUser.SteamID - } - - if !req.SourceID.Valid() { - httphelper.HandleErrBadRequest(ctx) - slog.Error("Invalid steam_id", slog.String("steamid", req.SourceID.String())) - - return - } - - if !req.TargetID.Valid() { - httphelper.HandleErrBadRequest(ctx) - slog.Error("Invalid target_id", slog.String("steamid", req.TargetID.String())) - - return - } - - if req.SourceID.Int64() == req.TargetID.Int64() { - httphelper.ResponseErr(ctx, http.StatusConflict, domain.ErrSelfReport) - - return - } - - personSource, errSource := h.persons.GetPersonBySteamID(ctx, req.SourceID) - if errSource != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Could not load player profile", log.ErrAttr(errSource)) - - return - } - - personTarget, errTarget := h.persons.GetOrCreatePersonBySteamID(ctx, req.TargetID) - if errTarget != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Could not load player profile", log.ErrAttr(errTarget)) - - return - } - - if personTarget.Expired() { - if err := thirdparty.UpdatePlayerSummary(ctx, &personTarget); err != nil { - slog.Error("Failed to update target player", log.ErrAttr(err)) - } else { - if errSave := h.persons.SavePerson(ctx, &personTarget); errSave != nil { - slog.Error("Failed to save target player update", log.ErrAttr(err)) - } - } - } - - // Ensure the user doesn't already have an open report against the user - existing, errReports := h.reports.GetReportBySteamID(ctx, personSource.SteamID, req.TargetID) - if errReports != nil { - if !errors.Is(errReports, domain.ErrNoResult) { - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to query reports by steam id", log.ErrAttr(errReports)) - - return - } - } - - if existing.ReportID > 0 { - httphelper.ResponseErr(ctx, http.StatusConflict, domain.ErrReportExists) - - return - } - - var demo domain.DemoFile - - if req.DemoID > 0 { - if errDemo := h.demos.GetDemoByID(ctx, req.DemoID, &demo); errDemo != nil { - httphelper.HandleErrBadRequest(ctx) - slog.Error("Failed to load demo for report", slog.Int64("demo_id", req.DemoID)) - - return - } - } - - // TODO encapsulate all operations in single tx - report := domain.NewReport() - report.SourceID = req.SourceID - report.ReportStatus = domain.Opened - report.Description = req.Description - report.TargetID = req.TargetID - report.Reason = req.Reason - report.ReasonText = req.ReasonText - report.DemoID = req.DemoID - report.DemoTick = req.DemoTick - report.PersonMessageID = req.PersonMessageID - - if errReportSave := h.reports.SaveReport(ctx, &report); errReportSave != nil { - httphelper.HandleErrInternal(ctx) + report, errReportSave := h.reports.SaveReport(ctx, currentUser, req) + if errReportSave != nil { + httphelper.HandleErrs(ctx, errReportSave) slog.Error("Failed to save report", log.ErrAttr(errReportSave)) return @@ -202,23 +65,7 @@ func (h reportHandler) onAPIPostReportCreate() gin.HandlerFunc { ctx.JSON(http.StatusCreated, report) - if demo.DemoID > 0 && !demo.Archive { - if errMark := h.demos.MarkArchived(ctx, &demo); errMark != nil { - slog.Error("Failed to mark demo as archived", log.ErrAttr(errMark)) - } - } - - conf := h.config.Config() - - demoURL := "" - - if report.DemoID > 0 { - demoURL = conf.ExtURLRaw("/asset/%s", demo.AssetID.String()) - } - - msg := discord.NewInGameReportResponse(report, conf.ExtURL(report), currentUser, conf.ExtURL(currentUser), demoURL) - - h.discord.SendPayload(domain.ChannelModAppealLog, msg) + slog.Info("New report created", slog.Int64("report_id", report.ReportID)) } } @@ -244,10 +91,6 @@ func (h reportHandler) onAPIGetReport() gin.HandlerFunc { } } -func (r reportUsecase) GetReportBySteamID(ctx context.Context, authorID steamid.SteamID, steamID steamid.SteamID) (domain.Report, error) { - return r.repository.GetReportBySteamID(ctx, authorID, steamID) -} - func (h reportHandler) onAPIGetUserReports() gin.HandlerFunc { return func(ctx *gin.Context) { user := httphelper.CurrentUserProfile(ctx) @@ -284,10 +127,6 @@ func (h reportHandler) onAPIGetAllReports() gin.HandlerFunc { } func (h reportHandler) onAPISetReportStatus() gin.HandlerFunc { - type stateUpdateReq struct { - Status domain.ReportStatus `json:"status"` - } - return func(ctx *gin.Context) { reportID, errParam := httphelper.GetInt64Param(ctx, "report_id") if errParam != nil { @@ -297,47 +136,24 @@ func (h reportHandler) onAPISetReportStatus() gin.HandlerFunc { return } - var req stateUpdateReq + var req domain.RequestReportStatusUpdate if !httphelper.Bind(ctx, &req) { return } - report, errGet := h.reports.GetReport(ctx, httphelper.CurrentUserProfile(ctx), reportID) - if errGet != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to get report to set state", log.ErrAttr(errGet)) - - return - } - - if report.ReportStatus == req.Status { - ctx.JSON(http.StatusConflict, domain.ErrDuplicate) - - return - } - - original := report.ReportStatus - - report.ReportStatus = req.Status - if errSave := h.reports.SaveReport(ctx, &report.Report); errSave != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to save report state", log.ErrAttr(errSave)) + report, err := h.reports.SetReportStatus(ctx, reportID, httphelper.CurrentUserProfile(ctx), req.Status) + if err != nil { + httphelper.HandleErrs(ctx, err) + slog.Error("Failed to set report status", log.ErrAttr(err), slog.Int64("report_id", reportID), slog.String("status", req.Status.String())) return } - ctx.JSON(http.StatusAccepted, nil) + ctx.JSON(http.StatusOK, gin.H{}) slog.Info("Report status changed", slog.Int64("report_id", report.ReportID), - slog.String("from_status", original.String()), slog.String("to_status", report.ReportStatus.String())) - // discord.SendDiscord(model.NotificationPayload{ - // Sids: steamid.Collection{report.SourceID}, - // Severity: db.SeverityInfo, - // Message: "Report status updated", - // Link: report.ToURL(), - // }) - } //nolint:wsl + } } func (h reportHandler) onAPIGetReportMessages() gin.HandlerFunc { @@ -379,10 +195,6 @@ func (h reportHandler) onAPIGetReportMessages() gin.HandlerFunc { } func (h reportHandler) onAPIPostReportMessage() gin.HandlerFunc { - type newMessage struct { - Message string `json:"message"` - } - return func(ctx *gin.Context) { reportID, errID := httphelper.GetInt64Param(ctx, "report_id") if errID != nil || reportID == 0 { @@ -395,128 +207,52 @@ func (h reportHandler) onAPIPostReportMessage() gin.HandlerFunc { return } - var req newMessage + var req domain.RequestMessageBodyMD if !httphelper.Bind(ctx, &req) { return } - if req.Message == "" { - httphelper.HandleErrBadRequest(ctx) - - return - } - - report, errReport := h.reports.GetReport(ctx, httphelper.CurrentUserProfile(ctx), reportID) - if errReport != nil { - if errors.Is(errReport, domain.ErrNoResult) { - httphelper.HandleErrNotFound(ctx) - - return - } - - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to load report", log.ErrAttr(errReport)) - - return - } - - person := httphelper.CurrentUserProfile(ctx) - msg := domain.NewReportMessage(reportID, person.SteamID, req.Message) - - if errSave := h.reports.SaveReportMessage(ctx, &msg); errSave != nil { - httphelper.ResponseErr(ctx, http.StatusInternalServerError, domain.ErrInternal) - slog.Error("Failed to save report message", log.ErrAttr(errSave)) - - return - } - - report.UpdatedOn = time.Now() + curUser := httphelper.CurrentUserProfile(ctx) - if errSave := h.reports.SaveReport(ctx, &report.Report); errSave != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to update report activity", log.ErrAttr(errSave)) + msg, errSave := h.reports.CreateReportMessage(ctx, reportID, curUser, req) + if errSave != nil { + httphelper.HandleErrs(ctx, errSave) + slog.Error("Failed to save report message", log.ErrAttr(errSave), slog.Int64("report_id", reportID)) return } ctx.JSON(http.StatusCreated, msg) - - conf := h.config.Config() - - h.discord.SendPayload(domain.ChannelModAppealLog, - discord.NewReportMessageResponse(msg.MessageMD, conf.ExtURL(report), person, conf.ExtURL(person))) + slog.Info("New report message created", + slog.Int64("report_id", reportID), slog.String("steam_id", curUser.SteamID.String())) } } func (h reportHandler) onAPIEditReportMessage() gin.HandlerFunc { - type editMessage struct { - BodyMD string `json:"body_md"` - } - return func(ctx *gin.Context) { reportMessageID, errID := httphelper.GetInt64Param(ctx, "report_message_id") - if errID != nil || reportMessageID == 0 { - httphelper.HandleErrBadRequest(ctx) - - if errID != nil { - slog.Warn("Failed to get report_message_id", log.ErrAttr(errID)) - } - - return - } - - existing, errExist := h.reports.GetReportMessageByID(ctx, reportMessageID) - if errExist != nil { - if errors.Is(errExist, domain.ErrNoResult) { - httphelper.HandleErrNotFound(ctx) - - return - } - - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to get report message by id", log.ErrAttr(errExist)) - - return - } + if errID != nil { + httphelper.HandleErrs(ctx, errID) + slog.Warn("Failed to get report_message_id", log.ErrAttr(errID)) - curUser := httphelper.CurrentUserProfile(ctx) - if !httphelper.HasPrivilege(curUser, steamid.Collection{existing.AuthorID}, domain.PModerator) { return } - var req editMessage + var req domain.RequestMessageBodyMD if !httphelper.Bind(ctx, &req) { return } - if req.BodyMD == "" { - httphelper.HandleErrBadRequest(ctx) - - return - } - - if req.BodyMD == existing.MessageMD { - httphelper.ResponseErr(ctx, http.StatusConflict, domain.ErrDuplicate) + msg, errMsg := h.reports.EditReportMessage(ctx, reportMessageID, httphelper.CurrentUserProfile(ctx), req) + if errMsg != nil { + httphelper.HandleErrs(ctx, errMsg) + slog.Error("Failed to edit report message", log.ErrAttr(errMsg)) return } - existing.MessageMD = req.BodyMD - if errSave := h.reports.SaveReportMessage(ctx, &existing); errSave != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to save report message", log.ErrAttr(errSave)) - - return - } - - ctx.JSON(http.StatusCreated, existing) - - conf := h.config.Config() - - msg := discord.EditReportMessageResponse(req.BodyMD, existing.MessageMD, - conf.ExtURLRaw("/report/%d", existing.ReportID), curUser, conf.ExtURL(curUser)) - - h.discord.SendPayload(domain.ChannelModAppealLog, msg) + ctx.JSON(http.StatusOK, msg) + slog.Info("Report message edited", slog.Int64("report_message_id", reportMessageID)) } } @@ -530,37 +266,14 @@ func (h reportHandler) onAPIDeleteReportMessage() gin.HandlerFunc { return } - existing, errExist := h.reports.GetReportMessageByID(ctx, reportMessageID) - if errExist != nil { - if errors.Is(errExist, domain.ErrNoResult) { - httphelper.HandleErrNotFound(ctx) - - return - } - - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to get report message by id", log.ErrAttr(errExist)) + if err := h.reports.DropReportMessage(ctx, httphelper.CurrentUserProfile(ctx), reportMessageID); err != nil { + httphelper.HandleErrs(ctx, err) + slog.Error("Failed to drop report message", log.ErrAttr(err)) return } - curUser := httphelper.CurrentUserProfile(ctx) - if !httphelper.HasPrivilege(curUser, steamid.Collection{existing.AuthorID}, domain.PModerator) { - return - } - - existing.Deleted = true - if errSave := h.reports.SaveReportMessage(ctx, &existing); errSave != nil { - httphelper.HandleErrInternal(ctx) - slog.Error("Failed to save report message", log.ErrAttr(errSave)) - - return - } - - ctx.JSON(http.StatusNoContent, nil) - - conf := h.config.Config() - - h.discord.SendPayload(domain.ChannelModAppealLog, discord.DeleteReportMessage(existing, curUser, conf.ExtURL(curUser))) + ctx.JSON(http.StatusOK, gin.H{}) + slog.Info("Deleted report message", slog.Int64("report_message_id", reportMessageID)) } } diff --git a/internal/report/report_usecase.go b/internal/report/report_usecase.go index d76325ee..b4ee1225 100644 --- a/internal/report/report_usecase.go +++ b/internal/report/report_usecase.go @@ -3,12 +3,14 @@ package report import ( "context" "errors" + "fmt" "log/slog" "time" "github.com/leighmacdonald/gbans/internal/discord" "github.com/leighmacdonald/gbans/internal/domain" "github.com/leighmacdonald/gbans/internal/httphelper" + "github.com/leighmacdonald/gbans/internal/thirdparty" "github.com/leighmacdonald/gbans/pkg/fp" "github.com/leighmacdonald/gbans/pkg/log" "github.com/leighmacdonald/steamid/v4/steamid" @@ -122,6 +124,29 @@ func (r reportUsecase) addAuthorsToReports(ctx context.Context, reports []domain return userReports, nil } +func (r reportUsecase) SetReportStatus(ctx context.Context, reportID int64, user domain.PersonInfo, status domain.ReportStatus) (domain.ReportWithAuthor, error) { + report, errGet := r.GetReport(ctx, user, reportID) + if errGet != nil { + return report, errGet + } + + if report.ReportStatus == status { + return report, domain.ErrDuplicate + } + + fromStatus := report.ReportStatus + + report.ReportStatus = status + + if errSave := r.repository.SaveReport(ctx, &report.Report); errSave != nil { + return report, errSave + } + + go r.discord.SendPayload(domain.ChannelMod, discord.ReportStatusChangeMessage(report, fromStatus, r.config.ExtURL(report))) + + return report, nil +} + func (r reportUsecase) GetReportsBySteamID(ctx context.Context, steamID steamid.SteamID) ([]domain.ReportWithAuthor, error) { if !steamID.Valid() { return nil, domain.ErrInvalidSID @@ -187,6 +212,10 @@ func (r reportUsecase) GetReport(ctx context.Context, curUser domain.PersonInfo, }, nil } +func (r reportUsecase) GetReportBySteamID(ctx context.Context, authorID steamid.SteamID, steamID steamid.SteamID) (domain.Report, error) { + return r.repository.GetReportBySteamID(ctx, authorID, steamID) +} + func (r reportUsecase) GetReportMessages(ctx context.Context, reportID int64) ([]domain.ReportMessage, error) { return r.repository.GetReportMessages(ctx, reportID) } @@ -195,24 +224,198 @@ func (r reportUsecase) GetReportMessageByID(ctx context.Context, reportMessageID return r.repository.GetReportMessageByID(ctx, reportMessageID) } -func (r reportUsecase) DropReportMessage(ctx context.Context, message *domain.ReportMessage) error { - return r.repository.DropReportMessage(ctx, message) +func (r reportUsecase) DropReportMessage(ctx context.Context, curUser domain.PersonInfo, reportMessageID int64) error { + existing, errExist := r.repository.GetReportMessageByID(ctx, reportMessageID) + if errExist != nil { + return errExist + } + + if !httphelper.HasPrivilege(curUser, steamid.Collection{existing.AuthorID}, domain.PModerator) { + return domain.ErrPermissionDenied + } + + if err := r.repository.DropReportMessage(ctx, &existing); err != nil { + return err + } + + go r.discord.SendPayload(domain.ChannelModAppealLog, discord.DeleteReportMessage(existing, curUser, r.config.ExtURL(curUser))) + + return nil } func (r reportUsecase) DropReport(ctx context.Context, report *domain.Report) error { return r.repository.DropReport(ctx, report) } -func (r reportUsecase) SaveReport(ctx context.Context, report *domain.Report) error { - if err := r.repository.SaveReport(ctx, report); err != nil { - return err +func (r reportUsecase) SaveReport(ctx context.Context, currentUser domain.UserProfile, req domain.RequestReportCreate) (domain.ReportWithAuthor, error) { + if req.Description == "" || len(req.Description) < 10 { + return domain.ReportWithAuthor{}, fmt.Errorf("%w: description", domain.ErrParamInvalid) + } + + // ServerStore initiated requests will have a sourceID set by the server + // Web based reports the source should not be set, the reporter will be taken from the + // current session information instead + if !req.SourceID.Valid() { + req.SourceID = currentUser.SteamID } - slog.Info("New report created", slog.Int64("report_id", report.ReportID)) + if !req.SourceID.Valid() { + return domain.ReportWithAuthor{}, fmt.Errorf("%w: source_id", domain.ErrParamInvalid) + } - return nil + if !req.TargetID.Valid() { + return domain.ReportWithAuthor{}, fmt.Errorf("%w: target_id", domain.ErrParamInvalid) + } + + if req.SourceID.Int64() == req.TargetID.Int64() { + return domain.ReportWithAuthor{}, fmt.Errorf("%w: cannot report self", domain.ErrParamInvalid) + } + + personSource, errSource := r.persons.GetPersonBySteamID(ctx, req.SourceID) + if errSource != nil { + return domain.ReportWithAuthor{}, errSource + } + + personTarget, errTarget := r.persons.GetOrCreatePersonBySteamID(ctx, req.TargetID) + if errTarget != nil { + return domain.ReportWithAuthor{}, errTarget + } + + if personTarget.Expired() { + if err := thirdparty.UpdatePlayerSummary(ctx, &personTarget); err != nil { + slog.Error("Failed to update target player", log.ErrAttr(err)) + } else { + if errSave := r.persons.SavePerson(ctx, &personTarget); errSave != nil { + slog.Error("Failed to save target player update", log.ErrAttr(err)) + } + } + } + + // Ensure the user doesn't already have an open report against the user + existing, errReports := r.GetReportBySteamID(ctx, personSource.SteamID, req.TargetID) + if errReports != nil { + if !errors.Is(errReports, domain.ErrNoResult) { + return domain.ReportWithAuthor{}, errReports + } + } + + if existing.ReportID > 0 { + return domain.ReportWithAuthor{}, domain.ErrReportExists + } + + var demo domain.DemoFile + + if req.DemoID > 0 { + if errDemo := r.demos.GetDemoByID(ctx, req.DemoID, &demo); errDemo != nil { + return domain.ReportWithAuthor{}, errDemo + } + } + + // TODO encapsulate all operations in single tx + report := domain.NewReport() + report.SourceID = req.SourceID + report.ReportStatus = domain.Opened + report.Description = req.Description + report.TargetID = req.TargetID + report.Reason = req.Reason + report.ReasonText = req.ReasonText + report.DemoID = req.DemoID + report.DemoTick = req.DemoTick + report.PersonMessageID = req.PersonMessageID + + if err := r.repository.SaveReport(ctx, &report); err != nil { + return domain.ReportWithAuthor{}, err + } + + if demo.DemoID > 0 && !demo.Archive { + if errMark := r.demos.MarkArchived(ctx, &demo); errMark != nil { + slog.Error("Failed to mark demo as archived", log.ErrAttr(errMark)) + } + } + + conf := r.config.Config() + + demoURL := "" + + if report.DemoID > 0 { + demoURL = conf.ExtURLRaw("/asset/%s", demo.AssetID.String()) + } + + newReport, errReport := r.GetReport(ctx, currentUser, report.ReportID) + if errReport != nil { + return domain.ReportWithAuthor{}, errReport + } + + go r.discord.SendPayload( + domain.ChannelModAppealLog, + discord.NewInGameReportResponse(newReport, conf.ExtURL(report), currentUser, conf.ExtURL(currentUser), demoURL)) + + return newReport, nil } -func (r reportUsecase) SaveReportMessage(ctx context.Context, message *domain.ReportMessage) error { - return r.repository.SaveReportMessage(ctx, message) +func (r reportUsecase) EditReportMessage(ctx context.Context, reportMessageID int64, curUser domain.PersonInfo, req domain.RequestMessageBodyMD) (domain.ReportMessage, error) { + if reportMessageID <= 0 { + return domain.ReportMessage{}, domain.ErrParamInvalid + } + + existing, errExist := r.GetReportMessageByID(ctx, reportMessageID) + if errExist != nil { + return domain.ReportMessage{}, errExist + } + + if !httphelper.HasPrivilege(curUser, steamid.Collection{existing.AuthorID}, domain.PModerator) { + return domain.ReportMessage{}, domain.ErrPermissionDenied + } + + if req.BodyMD == "" { + return domain.ReportMessage{}, domain.ErrInvalidParameter + } + + if req.BodyMD == existing.MessageMD { + return domain.ReportMessage{}, domain.ErrDuplicate + } + + existing.MessageMD = req.BodyMD + + if errSave := r.repository.SaveReportMessage(ctx, &existing); errSave != nil { + return domain.ReportMessage{}, errSave + } + + conf := r.config.Config() + + msg := discord.EditReportMessageResponse(req.BodyMD, existing.MessageMD, + conf.ExtURLRaw("/report/%d", existing.ReportID), curUser, conf.ExtURL(curUser)) + + go r.discord.SendPayload(domain.ChannelModAppealLog, msg) + + return r.GetReportMessageByID(ctx, reportMessageID) +} + +func (r reportUsecase) CreateReportMessage(ctx context.Context, reportID int64, curUser domain.PersonInfo, req domain.RequestMessageBodyMD) (domain.ReportMessage, error) { + if req.BodyMD == "" { + return domain.ReportMessage{}, domain.ErrParamInvalid + } + + report, errReport := r.GetReport(ctx, curUser, reportID) + if errReport != nil { + return domain.ReportMessage{}, errReport + } + + msg := domain.NewReportMessage(reportID, curUser.GetSteamID(), req.BodyMD) + if err := r.repository.SaveReportMessage(ctx, &msg); err != nil { + return domain.ReportMessage{}, err + } + + report.UpdatedOn = time.Now() + + if errSave := r.repository.SaveReport(ctx, &report.Report); errSave != nil { + return domain.ReportMessage{}, errSave + } + + conf := r.config.Config() + + go r.discord.SendPayload(domain.ChannelModAppealLog, + discord.NewReportMessageResponse(msg.MessageMD, conf.ExtURL(report), curUser, conf.ExtURL(curUser))) + + return msg, nil } diff --git a/internal/srcds/srcds_service.go b/internal/srcds/srcds_service.go index 9ce21367..aaeae19a 100644 --- a/internal/srcds/srcds_service.go +++ b/internal/srcds/srcds_service.go @@ -834,7 +834,7 @@ func (s *srcdsHandler) onAPIPostReportCreate() gin.HandlerFunc { return func(ctx *gin.Context) { currentUser := httphelper.CurrentUserProfile(ctx) - var req domain.CreateReportReq + var req domain.RequestReportCreate if !httphelper.Bind(ctx, &req) { return } @@ -842,12 +842,13 @@ func (s *srcdsHandler) onAPIPostReportCreate() gin.HandlerFunc { report, errReport := s.srcds.Report(ctx, currentUser, req) if errReport != nil { httphelper.HandleErrInternal(ctx) - slog.Error("Failed to create report", log.ErrAttr(errReport)) + slog.Error("Failed to create report", log.ErrAttr(errReport), slog.String("method", "in-game")) return } ctx.JSON(http.StatusCreated, report) + slog.Info("New report created successfully", slog.Int64("report_id", report.ReportID), slog.String("method", "in-game")) } } diff --git a/internal/srcds/srcds_usecase.go b/internal/srcds/srcds_usecase.go index c00bb69a..8036ca25 100644 --- a/internal/srcds/srcds_usecase.go +++ b/internal/srcds/srcds_usecase.go @@ -300,9 +300,9 @@ func (h srcds) GetAdminGroups(ctx context.Context, admin domain.SMAdmin) ([]doma return h.repository.GetAdminGroups(ctx, admin) } -func (h srcds) Report(ctx context.Context, currentUser domain.UserProfile, req domain.CreateReportReq) (*domain.Report, error) { +func (h srcds) Report(ctx context.Context, currentUser domain.UserProfile, req domain.RequestReportCreate) (domain.ReportWithAuthor, error) { if req.Description == "" || len(req.Description) < 10 { - return nil, fmt.Errorf("%w: description", domain.ErrParamInvalid) + return domain.ReportWithAuthor{}, fmt.Errorf("%w: description", domain.ErrParamInvalid) } // ServerStore initiated requests will have a sourceID set by the server @@ -313,25 +313,25 @@ func (h srcds) Report(ctx context.Context, currentUser domain.UserProfile, req d } if !req.SourceID.Valid() { - return nil, domain.ErrSourceID + return domain.ReportWithAuthor{}, domain.ErrSourceID } if !req.TargetID.Valid() { - return nil, domain.ErrTargetID + return domain.ReportWithAuthor{}, domain.ErrTargetID } if req.SourceID.Int64() == req.TargetID.Int64() { - return nil, domain.ErrSelfReport + return domain.ReportWithAuthor{}, domain.ErrSelfReport } - personSource, errCreatePerson := h.persons.GetPersonBySteamID(ctx, req.SourceID) - if errCreatePerson != nil { - return nil, domain.ErrInternal + personSource, errCreateSource := h.persons.GetPersonBySteamID(ctx, req.SourceID) + if errCreateSource != nil { + return domain.ReportWithAuthor{}, errCreateSource } - personTarget, errCreatePerson := h.persons.GetOrCreatePersonBySteamID(ctx, req.TargetID) - if errCreatePerson != nil { - return nil, domain.ErrInternal + personTarget, errCreateTarget := h.persons.GetOrCreatePersonBySteamID(ctx, req.TargetID) + if errCreateTarget != nil { + return domain.ReportWithAuthor{}, errCreateTarget } if personTarget.Expired() { @@ -348,40 +348,28 @@ func (h srcds) Report(ctx context.Context, currentUser domain.UserProfile, req d existing, errReports := h.reports.GetReportBySteamID(ctx, personSource.SteamID, req.TargetID) if errReports != nil { if !errors.Is(errReports, domain.ErrNoResult) { - return nil, errReports + return domain.ReportWithAuthor{}, errReports } } if existing.ReportID > 0 { - return nil, domain.ErrReportExists + return domain.ReportWithAuthor{}, domain.ErrReportExists } - // TODO encapsulate all operations in single tx - report := domain.NewReport() - report.SourceID = req.SourceID - report.ReportStatus = domain.Opened - report.Description = req.Description - report.TargetID = req.TargetID - report.Reason = req.Reason - report.ReasonText = req.ReasonText - report.DemoTick = req.DemoTick - report.PersonMessageID = req.PersonMessageID - - if errReportSave := h.reports.SaveReport(ctx, &report); errReportSave != nil { - return nil, errReportSave + savedReport, errReportSave := h.reports.SaveReport(ctx, currentUser, req) + if errReportSave != nil { + return domain.ReportWithAuthor{}, errReportSave } - slog.Info("New report created successfully", slog.Int64("report_id", report.ReportID)) - conf := h.config.Config() demoURL := "" - msg := discord.NewInGameReportResponse(report, conf.ExtURL(report), currentUser, conf.ExtURL(currentUser), demoURL) + msg := discord.NewInGameReportResponse(savedReport, conf.ExtURL(savedReport), currentUser, conf.ExtURL(currentUser), demoURL) - h.discord.SendPayload(domain.ChannelModLog, msg) + go h.discord.SendPayload(domain.ChannelModLog, msg) - return &report, nil + return savedReport, nil } func (h srcds) SetAdminGroups(ctx context.Context, authType domain.AuthType, identity string, groups ...domain.SMGroups) error { diff --git a/internal/test/config_test.go b/internal/test/config_test.go index c0a9e8d5..a9359fac 100644 --- a/internal/test/config_test.go +++ b/internal/test/config_test.go @@ -19,7 +19,7 @@ func TestConfig(t *testing.T) { require.EqualValues(t, configUC.Config(), config) config.General.SiteName += "x" - config.General.Mode = domain.DebugMode + config.General.Mode = domain.TestMode config.General.FileServeMode = "local" config.General.SrcdsLogAddr += "x" config.General.AssetURL += "x" diff --git a/internal/test/helpers_test.go b/internal/test/helpers_test.go index 2bc9a21a..3d249ebc 100644 --- a/internal/test/helpers_test.go +++ b/internal/test/helpers_test.go @@ -6,6 +6,7 @@ import ( "fmt" "testing" + "github.com/docker/docker/api/types/container" "github.com/gin-gonic/gin" "github.com/leighmacdonald/gbans/internal/domain" "github.com/leighmacdonald/gbans/pkg/fs" @@ -26,8 +27,8 @@ type postgresContainer struct { } func newDB(ctx context.Context) (*postgresContainer, error) { - if container != nil { - return container, nil + if dbContainer != nil { + return dbContainer, nil } const testInfo = "gbans-test" @@ -45,6 +46,9 @@ func newDB(ctx context.Context) (*postgresContainer, error) { cont, errContainer := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ FromDockerfile: fromDockerfile, + HostConfigModifier: func(config *container.HostConfig) { + config.AutoRemove = false + }, Env: map[string]string{ "POSTGRES_DB": dbName, "POSTGRES_USER": username, diff --git a/internal/test/main_test.go b/internal/test/main_test.go index 7652dbe3..514f22c4 100644 --- a/internal/test/main_test.go +++ b/internal/test/main_test.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" "github.com/leighmacdonald/gbans/internal/app" + "github.com/leighmacdonald/gbans/internal/appeal" "github.com/leighmacdonald/gbans/internal/asset" "github.com/leighmacdonald/gbans/internal/auth" "github.com/leighmacdonald/gbans/internal/ban" @@ -49,7 +50,7 @@ import ( ) var ( - container *postgresContainer + dbContainer *postgresContainer testServer domain.Server configUC domain.ConfigUsecase wikiUC domain.WikiUsecase @@ -76,13 +77,14 @@ var ( votesUC domain.VoteUsecase votesRepo domain.VoteRepository wordFilterUC domain.WordFilterUsecase + appealUC domain.AppealUsecase ) func TestMain(m *testing.M) { testCtx, cancel := context.WithTimeout(context.Background(), time.Minute*2) defer cancel() - dbContainer, errStore := newDB(testCtx) + testDB, errStore := newDB(testCtx) if errStore != nil { panic(errStore) } @@ -91,17 +93,17 @@ func TestMain(m *testing.M) { termCtx, termCancel := context.WithTimeout(context.Background(), time.Second*30) defer termCancel() - if errTerm := container.Terminate(termCtx); errTerm != nil { + if errTerm := testDB.Terminate(termCtx); errTerm != nil { panic(fmt.Sprintf("Failed to terminate test container: %v", errTerm)) } }() - databaseConn := database.New(dbContainer.dsn, true, false) + databaseConn := database.New(testDB.dsn, true, false) if err := databaseConn.Connect(testCtx); err != nil { panic(err) } - conf := makeTestConfig(dbContainer.dsn) + conf := makeTestConfig(testDB.dsn) eventBroadcaster := fp.NewBroadcaster[logparse.EventType, logparse.ServerEvent]() weaponsMap := fp.NewMutexMap[logparse.Weapon, int]() @@ -144,8 +146,8 @@ func TestMain(m *testing.M) { chatUC = chat.NewChatUsecase(configUC, chat.NewChatRepository(databaseConn, personUC, wordFilterUC, matchUC, eventBroadcaster), wordFilterUC, stateUC, banSteamUC, personUC, discordUC) votesRepo = votes.NewVoteRepository(databaseConn) votesUC = votes.NewVoteUsecase(votesRepo, personUC, matchUC, discordUC, configUC, eventBroadcaster) - - container = dbContainer + appealUC = appeal.NewAppealUsecase(appeal.NewAppealRepository(databaseConn), banSteamUC, personUC, discordUC, configUC) + dbContainer = testDB server, errServer := serversUC.Save(context.Background(), domain.RequestServerUpdate{ ServerName: stringutil.SecureRandomString(20), @@ -192,6 +194,8 @@ func testRouter() *gin.Engine { wiki.NewWIkiHandler(router, wikiUC, authUC) votes.NewVoteHandler(router, votesUC, authUC) config.NewConfigHandler(router, configUC, authUC, app.Version()) + report.NewReportHandler(router, reportUC, authUC) + appeal.NewAppealHandler(router, appealUC, banSteamUC, configUC, personUC, discordUC, authUC) return router } diff --git a/internal/test/report_test.go b/internal/test/report_test.go new file mode 100644 index 00000000..9bdf34fa --- /dev/null +++ b/internal/test/report_test.go @@ -0,0 +1,160 @@ +package test_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/leighmacdonald/gbans/internal/domain" + "github.com/leighmacdonald/gbans/pkg/stringutil" + "github.com/stretchr/testify/require" +) + +func TestReport(t *testing.T) { + router := testRouter() + source := getUser() + sourceCreds := loginUser(source) + mods := loginUser(getModerator()) + otherUser := loginUser(getUser()) + target := getUser() + + // Create a report + req := domain.RequestReportCreate{ + SourceID: source.SteamID, + TargetID: target.SteamID, + Description: stringutil.SecureRandomString(100), + Reason: domain.Cheating, + ReasonText: "", + DemoID: 0, + DemoTick: 0, + PersonMessageID: 0, + } + var report domain.Report + testEndpointWithReceiver(t, router, http.MethodPost, "/api/report", req, http.StatusCreated, sourceCreds, &report) + require.EqualValues(t, req.SourceID, report.SourceID) + require.EqualValues(t, req.TargetID, report.TargetID) + require.EqualValues(t, req.Description, report.Description) + require.EqualValues(t, req.Reason, report.Reason) + require.EqualValues(t, req.ReasonText, report.ReasonText) + require.EqualValues(t, req.DemoID, report.DemoID) + require.EqualValues(t, req.DemoTick, report.DemoTick) + require.EqualValues(t, req.PersonMessageID, report.PersonMessageID) + + // Make sure we can query it + var fetched domain.Report + testEndpointWithReceiver(t, router, http.MethodGet, fmt.Sprintf("/api/report/%d", report.ReportID), nil, http.StatusOK, sourceCreds, &fetched) + require.EqualValues(t, report, fetched) + + // Make sure we can query all + var fetchedColl []domain.Report + testEndpointWithReceiver(t, router, http.MethodGet, "/api/reports/user", nil, http.StatusOK, sourceCreds, &fetchedColl) + require.NotEmpty(t, fetchedColl) + + var fetchedModColl []domain.Report + testEndpointWithReceiver(t, router, http.MethodPost, "/api/reports", domain.ReportQueryFilter{Deleted: true}, http.StatusOK, mods, &fetchedModColl) + require.NotEmpty(t, fetchedModColl) + + // Make sure others cant query other users reports + testEndpointWithReceiver(t, router, http.MethodGet, "/api/reports/user", nil, http.StatusOK, otherUser, &fetchedColl) + require.Empty(t, fetchedColl) + + // Change the status + statusReq := domain.RequestReportStatusUpdate{Status: domain.ClosedWithAction} + testEndpoint(t, router, http.MethodPost, fmt.Sprintf("/api/report_status/%d", report.ReportID), statusReq, http.StatusOK, sourceCreds) + + testEndpointWithReceiver(t, router, http.MethodGet, fmt.Sprintf("/api/report/%d", report.ReportID), nil, http.StatusOK, sourceCreds, &fetched) + require.Equal(t, statusReq.Status, fetched.ReportStatus) + + // Get empty child messages + var messages []domain.ReportMessage + testEndpointWithReceiver(t, router, http.MethodGet, fmt.Sprintf("/api/report/%d/messages", report.ReportID), nil, http.StatusOK, sourceCreds, &messages) + require.Empty(t, messages) + + // Add a reply + var fetchedMsg domain.ReportMessage + msgReq := domain.RequestMessageBodyMD{BodyMD: stringutil.SecureRandomString(100)} + testEndpointWithReceiver(t, router, http.MethodPost, fmt.Sprintf("/api/report/%d/messages", report.ReportID), msgReq, http.StatusCreated, sourceCreds, &fetchedMsg) + require.Equal(t, msgReq.BodyMD, fetchedMsg.MessageMD) + + // Get the reply + testEndpointWithReceiver(t, router, http.MethodGet, fmt.Sprintf("/api/report/%d/messages", report.ReportID), nil, http.StatusOK, sourceCreds, &messages) + require.NotEmpty(t, messages) + + // Edit the reply + editMsgReq := domain.RequestMessageBodyMD{BodyMD: stringutil.SecureRandomString(100)} + var edited domain.ReportMessage + testEndpointWithReceiver(t, router, http.MethodPost, fmt.Sprintf("/api/report/message/%d", report.ReportID), editMsgReq, http.StatusOK, sourceCreds, &edited) + require.Equal(t, editMsgReq.BodyMD, edited.MessageMD) + + // Delete the message + testEndpoint(t, router, http.MethodDelete, fmt.Sprintf("/api/report/message/%d", fetchedMsg.ReportMessageID), nil, http.StatusOK, sourceCreds) + + // Make sure it was deleted + testEndpointWithReceiver(t, router, http.MethodGet, fmt.Sprintf("/api/report/%d/messages", report.ReportID), nil, http.StatusOK, sourceCreds, &messages) + require.Empty(t, messages) +} + +func TestReportPermissions(t *testing.T) { + testPermissions(t, testRouter(), []permTestValues{ + { + path: "/api/report", + method: http.MethodPost, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/report/1", + method: http.MethodGet, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/report_status/1", + method: http.MethodPost, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/report/1/messages", + method: http.MethodGet, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/report/1/messages", + method: http.MethodPost, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/report/message/1", + method: http.MethodPost, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/report/message/1", + method: http.MethodDelete, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/reports/user", + method: http.MethodPost, + code: http.StatusForbidden, + levels: authed, + }, + { + path: "/api/report/1/state", + method: http.MethodPost, + code: http.StatusForbidden, + levels: moderators, + }, + { + path: "/api/reports", + method: http.MethodPost, + code: http.StatusForbidden, + levels: moderators, + }, + }) +}