mirror of
https://github.com/mattermost/mattermost.git
synced 2026-04-13 04:57:45 -04:00
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
* Add single-channel guests filter and channel count column to System Console Users - Add guest_filter query parameter to Reports API with store-level filtering by guest channel membership count (all, single_channel, multi_channel) - Add channel_count field to user report responses and CSV exports - Add grouped guest role filter options in the filter popover - Add toggleable Channel count column to the users table - Add GuestFilter and SearchTerm to Go client GetUsersForReporting - Add tests: API parsing, API integration, app job dedup, webapp utils, E2E column data rendering Made-with: Cursor * Fix gofmt alignment and isolate guest store tests - Align GuestFilter constants to satisfy gofmt - Move guest user/channel setup into a nested sub-test to avoid breaking existing ordering and role filter assertions Made-with: Cursor * Exclude archived channels from guest filter queries and ChannelCount The ChannelMembers subqueries for guest_filter (single/multi channel) and the ChannelCount column did not join with Channels to check DeleteAt = 0. Since channel archival soft-deletes (sets DeleteAt) but leaves ChannelMembers rows intact, archived channel memberships were incorrectly counted, potentially misclassifying guests between single-channel and multi-channel filters and inflating ChannelCount. - Join ChannelMembers with Channels (DeleteAt = 0) in all three subqueries in applyUserReportFilter and GetUserReport - Add store test covering archived channel exclusion - Tighten existing guest filter test assertions with found-flags and exact count checks Made-with: Cursor * Exclude DM/GM from guest channel counts, validate GuestFilter, fix dropdown divider - Scope ChannelCount and guest filter subqueries to Open/Private channel types only (exclude DM and GM), so a guest with one team channel plus a DM is correctly classified as single-channel - Add GuestFilter validation in UserReportOptions.IsValid with AllowedGuestFilters whitelist - Add API test for invalid guest_filter rejection (400) - Add store regression test for DM/GM exclusion - Fix role filter dropdown: hide the divider above the first group heading via CSS rule on DropDown__group:first-child - Update E2E test label to match "Guests in a single channel" wording Made-with: Cursor * Add store test coverage for private and GM channel types Private channels (type P) should be counted in ChannelCount and guest filters, while GM channels (type G) should not. Add a test that creates a guest with memberships in an open channel, a private channel, and a GM, then asserts ChannelCount = 2, multi-channel filter includes the guest, and single-channel filter excludes them. Made-with: Cursor * Add server i18n translation for invalid_guest_filter error The new error ID model.user_report_options.is_valid.invalid_guest_filter was missing from server/i18n/en.json, causing CI to fail. Made-with: Cursor * Make filter dropdown dividers full width Remove the horizontal inset from grouped dropdown separators so the system user role filter dividers span edge to edge across the menu. Leave the unrelated webapp/package-lock.json change uncommitted. Made-with: Cursor * Optimize guest channel report filters. Use per-user channel count subqueries for the single- and multi-channel guest filters so the report avoids aggregating all channel memberships before filtering guests.
829 lines
27 KiB
Go
829 lines
27 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package api4
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
)
|
|
|
|
// Helper function to make POST requests with JSON body
|
|
func doPostJSON(t *testing.T, client *model.Client4, endpoint string, body any) (*http.Response, []byte) {
|
|
jsonBytes, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest("POST", client.URL+endpoint, bytes.NewBuffer(jsonBytes))
|
|
require.NoError(t, err)
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(model.HeaderAuth, model.HeaderBearer+" "+client.AuthToken)
|
|
|
|
resp, err := client.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
|
|
defer resp.Body.Close()
|
|
responseBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
return resp, responseBytes
|
|
}
|
|
|
|
func TestGetUsersForReporting(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
client := th.Client
|
|
|
|
defaultRolePermissions := th.SaveDefaultRolePermissions(t)
|
|
defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
|
|
|
|
t.Run("should return forbidden error when user lacks permission", func(t *testing.T) {
|
|
th.RemovePermissionFromRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
|
|
|
|
_, resp, err := client.GetUsersForReporting(context.Background(), &model.UserReportOptions{})
|
|
require.Error(t, err)
|
|
CheckForbiddenStatus(t, resp)
|
|
})
|
|
|
|
t.Run("should return user reports when user has permission", func(t *testing.T) {
|
|
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
|
|
|
|
options := &model.UserReportOptions{
|
|
ReportingBaseOptions: model.ReportingBaseOptions{
|
|
PageSize: 10,
|
|
},
|
|
}
|
|
|
|
userReports, resp, err := client.GetUsersForReporting(context.Background(), options)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, userReports)
|
|
require.GreaterOrEqual(t, len(userReports), 1)
|
|
CheckOKStatus(t, resp)
|
|
})
|
|
|
|
t.Run("should return bad request on invalid parameters", func(t *testing.T) {
|
|
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
|
|
|
|
options := &model.UserReportOptions{
|
|
Team: "invalid_team_id",
|
|
}
|
|
|
|
_, resp, err := client.GetUsersForReporting(context.Background(), options)
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
|
|
t.Run("should filter by guest_filter single_channel and return channel_count", func(t *testing.T) {
|
|
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
|
|
|
|
// Create a guest user with exactly one channel membership
|
|
singleChannelGuest := th.CreateUser(t)
|
|
_, appErr := th.App.UpdateUserRoles(th.Context, singleChannelGuest.Id, model.SystemGuestRoleId, false)
|
|
require.Nil(t, appErr)
|
|
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, singleChannelGuest.Id, "")
|
|
require.Nil(t, appErr)
|
|
_, appErr = th.App.AddUserToChannel(th.Context, singleChannelGuest, th.BasicChannel, false)
|
|
require.Nil(t, appErr)
|
|
|
|
// Create a guest user with two channel memberships
|
|
multiChannelGuest := th.CreateUser(t)
|
|
_, appErr = th.App.UpdateUserRoles(th.Context, multiChannelGuest.Id, model.SystemGuestRoleId, false)
|
|
require.Nil(t, appErr)
|
|
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, multiChannelGuest.Id, "")
|
|
require.Nil(t, appErr)
|
|
_, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel, false)
|
|
require.Nil(t, appErr)
|
|
_, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel2, false)
|
|
require.Nil(t, appErr)
|
|
|
|
options := &model.UserReportOptions{
|
|
ReportingBaseOptions: model.ReportingBaseOptions{
|
|
PageSize: 100,
|
|
},
|
|
GuestFilter: model.GuestFilterSingleChannel,
|
|
}
|
|
|
|
userReports, resp, err := client.GetUsersForReporting(context.Background(), options)
|
|
require.NoError(t, err)
|
|
CheckOKStatus(t, resp)
|
|
|
|
foundSingle := false
|
|
for _, report := range userReports {
|
|
require.Contains(t, report.Roles, "system_guest")
|
|
require.NotNil(t, report.ChannelCount)
|
|
|
|
if report.Id == singleChannelGuest.Id {
|
|
foundSingle = true
|
|
require.Equal(t, 1, *report.ChannelCount)
|
|
}
|
|
require.NotEqual(t, multiChannelGuest.Id, report.Id)
|
|
}
|
|
require.True(t, foundSingle, "single-channel guest not found in results")
|
|
})
|
|
|
|
t.Run("should reject invalid guest_filter value", func(t *testing.T) {
|
|
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
|
|
|
|
options := &model.UserReportOptions{
|
|
ReportingBaseOptions: model.ReportingBaseOptions{
|
|
PageSize: 50,
|
|
},
|
|
GuestFilter: "invalid_value",
|
|
}
|
|
|
|
_, resp, err := client.GetUsersForReporting(context.Background(), options)
|
|
require.Error(t, err)
|
|
CheckBadRequestStatus(t, resp)
|
|
})
|
|
}
|
|
|
|
func TestFillReportingBaseOptions(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
t.Run("default values", func(t *testing.T) {
|
|
values := url.Values{}
|
|
|
|
options := fillReportingBaseOptions(values)
|
|
|
|
require.Equal(t, "Username", options.SortColumn)
|
|
require.Equal(t, "next", options.Direction)
|
|
require.Equal(t, false, options.SortDesc)
|
|
require.Equal(t, 50, options.PageSize)
|
|
require.Equal(t, "", options.FromColumnValue)
|
|
require.Equal(t, "", options.FromId)
|
|
require.Equal(t, "", options.DateRange)
|
|
})
|
|
|
|
t.Run("custom values", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("sort_column", "Email")
|
|
values.Set("direction", "prev")
|
|
values.Set("sort_direction", "desc")
|
|
values.Set("page_size", "25")
|
|
values.Set("from_column_value", "some_value")
|
|
values.Set("from_id", "some_id")
|
|
values.Set("date_range", "last_seven")
|
|
|
|
options := fillReportingBaseOptions(values)
|
|
|
|
require.Equal(t, "Email", options.SortColumn)
|
|
require.Equal(t, "prev", options.Direction)
|
|
require.Equal(t, true, options.SortDesc)
|
|
require.Equal(t, 25, options.PageSize)
|
|
require.Equal(t, "some_value", options.FromColumnValue)
|
|
require.Equal(t, "some_id", options.FromId)
|
|
require.Equal(t, "last_seven", options.DateRange)
|
|
})
|
|
|
|
t.Run("invalid page_size", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("page_size", "an_very_invalid_number")
|
|
|
|
options := fillReportingBaseOptions(values)
|
|
|
|
require.Equal(t, 50, options.PageSize)
|
|
})
|
|
|
|
t.Run("invalid direction", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("direction", "a_crazy_direction")
|
|
|
|
options := fillReportingBaseOptions(values)
|
|
|
|
require.Equal(t, "next", options.Direction)
|
|
})
|
|
}
|
|
|
|
func TestFillUserReportOptions(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
validTeamID := model.NewId()
|
|
|
|
t.Run("default values", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("team_filter", validTeamID)
|
|
|
|
options, _ := fillUserReportOptions(values)
|
|
|
|
expected := &model.UserReportOptions{
|
|
Team: validTeamID,
|
|
Role: "",
|
|
HasNoTeam: false,
|
|
HideActive: false,
|
|
HideInactive: false,
|
|
SearchTerm: "",
|
|
}
|
|
|
|
require.Equal(t, expected, options)
|
|
})
|
|
|
|
t.Run("empty team_filter", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("team_filter", "")
|
|
|
|
options, _ := fillUserReportOptions(values)
|
|
|
|
require.Equal(t, "", options.Team)
|
|
})
|
|
|
|
t.Run("valid team_filter", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("team_filter", validTeamID)
|
|
|
|
options, _ := fillUserReportOptions(values)
|
|
|
|
require.Equal(t, validTeamID, options.Team)
|
|
})
|
|
|
|
t.Run("guest_filter all", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("guest_filter", "all")
|
|
|
|
options, appErr := fillUserReportOptions(values)
|
|
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, "all", options.GuestFilter)
|
|
})
|
|
|
|
t.Run("guest_filter single_channel", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("guest_filter", "single_channel")
|
|
|
|
options, appErr := fillUserReportOptions(values)
|
|
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, "single_channel", options.GuestFilter)
|
|
})
|
|
|
|
t.Run("guest_filter multi_channel", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("guest_filter", "multi_channel")
|
|
|
|
options, appErr := fillUserReportOptions(values)
|
|
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, "multi_channel", options.GuestFilter)
|
|
})
|
|
|
|
t.Run("guest_filter defaults to empty when not provided", func(t *testing.T) {
|
|
values := url.Values{}
|
|
|
|
options, appErr := fillUserReportOptions(values)
|
|
|
|
require.Nil(t, appErr)
|
|
require.Equal(t, "", options.GuestFilter)
|
|
})
|
|
}
|
|
|
|
func TestGetPostsForReporting(t *testing.T) {
|
|
mainHelper.Parallel(t)
|
|
th := Setup(t).InitBasic(t)
|
|
|
|
// Set up Enterprise license for compliance/reporting features
|
|
license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)
|
|
th.App.Srv().SetLicense(license)
|
|
|
|
// Create test posts with controlled timestamps using App layer
|
|
// Setting CreateAt explicitly allows us to test time-based queries accurately
|
|
// This is a common pattern in API tests (see post_test.go for similar usage)
|
|
baseTime := model.GetMillis()
|
|
var testPosts []*model.Post
|
|
for i := range 15 {
|
|
post := &model.Post{
|
|
ChannelId: th.BasicChannel.Id,
|
|
UserId: th.BasicUser.Id,
|
|
Message: "Test post " + strconv.Itoa(i),
|
|
CreateAt: baseTime + (int64(i) * 1000), // 1 second apart
|
|
UpdateAt: baseTime + (int64(i) * 1000),
|
|
}
|
|
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
|
|
require.Nil(t, appErr)
|
|
testPosts = append(testPosts, createdPost)
|
|
}
|
|
|
|
t.Run("should return bad request when license is not Enterprise", func(t *testing.T) {
|
|
// Remove license temporarily
|
|
th.App.Srv().SetLicense(nil)
|
|
defer func() {
|
|
// Restore Enterprise license for remaining tests
|
|
license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)
|
|
th.App.Srv().SetLicense(license)
|
|
}()
|
|
|
|
th.LoginSystemAdmin(t)
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("should return bad request when license is Professional (not Enterprise)", func(t *testing.T) {
|
|
// Set Professional license (not sufficient)
|
|
professionalLicense := model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)
|
|
th.App.Srv().SetLicense(professionalLicense)
|
|
defer func() {
|
|
// Restore Enterprise license for remaining tests
|
|
license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)
|
|
th.App.Srv().SetLicense(license)
|
|
}()
|
|
|
|
th.LoginSystemAdmin(t)
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("should return forbidden error when user lacks permission", func(t *testing.T) {
|
|
th.LoginBasic(t)
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.Client, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("should return posts when system admin makes request", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result model.ReportPostListResponse
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result.Posts)
|
|
require.GreaterOrEqual(t, len(result.Posts), 10)
|
|
})
|
|
|
|
t.Run("should validate required channel_id parameter", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
requestBody := map[string]any{
|
|
"cursor": "",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("should return 404 for non-existent channel", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Use a valid ID format but for a channel that doesn't exist
|
|
nonExistentChannelId := model.NewId()
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": nonExistentChannelId,
|
|
"cursor": "",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode, "should return 404 for non-existent channel")
|
|
})
|
|
|
|
t.Run("should accept empty cursor for first page", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Empty cursor is valid for first page
|
|
// This is the documented behavior in OpenAPI spec and mmctl default
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "", // Empty cursor for first page
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "empty cursor should be accepted")
|
|
|
|
var result model.ReportPostListResponse
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result.Posts)
|
|
require.GreaterOrEqual(t, len(result.Posts), 10, "should return posts with empty cursor")
|
|
})
|
|
|
|
t.Run("should accept omitted cursor for first page", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Cursor can be omitted entirely for first page
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"sort_direction": "asc",
|
|
"per_page": 10,
|
|
// cursor omitted
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "omitted cursor should be accepted")
|
|
|
|
var result model.ReportPostListResponse
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result.Posts)
|
|
require.GreaterOrEqual(t, len(result.Posts), 10, "should return posts")
|
|
})
|
|
|
|
t.Run("should reject invalid cursor format", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "invalid:cursor", // Invalid cursor format
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "invalid cursor format should be rejected")
|
|
})
|
|
|
|
t.Run("should handle cursor pagination correctly", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// First page
|
|
requestBody1 := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"per_page": 5,
|
|
}
|
|
|
|
resp1, body1 := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody1)
|
|
require.Equal(t, http.StatusOK, resp1.StatusCode)
|
|
|
|
var result1 model.ReportPostListResponse
|
|
err1 := json.Unmarshal(body1, &result1)
|
|
require.NoError(t, err1)
|
|
require.Len(t, result1.Posts, 5)
|
|
require.NotNil(t, result1.NextCursor, "should have next cursor for more pages")
|
|
|
|
// Second page using cursor from first page
|
|
requestBody2 := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": result1.NextCursor.Cursor, // Opaque cursor from previous response
|
|
"per_page": 5,
|
|
}
|
|
|
|
resp2, body2 := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody2)
|
|
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
|
|
|
var result2 model.ReportPostListResponse
|
|
err2 := json.Unmarshal(body2, &result2)
|
|
require.NoError(t, err2)
|
|
require.Len(t, result2.Posts, 5)
|
|
|
|
// Verify no duplicate posts between pages
|
|
for _, post1 := range result1.Posts {
|
|
for _, post2 := range result2.Posts {
|
|
require.NotEqual(t, post1.Id, post2.Id, "should not have duplicate posts across pages")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("should support DESC sort order", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Start from future time and go backwards
|
|
futureTime := baseTime + (20 * 1000) // Well after all test posts
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"sort_direction": "desc",
|
|
"per_page": 5,
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result model.ReportPostListResponse
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Posts, 5, "should get 5 posts")
|
|
|
|
// Verify all posts are within expected time range
|
|
// Since the API returns a map, we can't check ordering directly,
|
|
// but we can verify all posts are from the expected range
|
|
for _, post := range result.Posts {
|
|
require.LessOrEqual(t, post.CreateAt, futureTime, "post should be before cursor_time")
|
|
require.GreaterOrEqual(t, post.CreateAt, baseTime, "post should be within expected range")
|
|
}
|
|
|
|
// The fact that we got exactly 5 posts and they're all in range
|
|
// validates that DESC pagination is working correctly
|
|
})
|
|
|
|
t.Run("should use cursor parameters when cursor is provided (self-contained cursor)", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Create a cursor with sort_direction=desc
|
|
cursor := model.EncodeReportPostCursor(
|
|
th.BasicChannel.Id,
|
|
"create_at",
|
|
false, // include_deleted
|
|
false, // exclude_system_posts
|
|
"desc",
|
|
baseTime,
|
|
"",
|
|
)
|
|
|
|
// Make request with different parameters - cursor parameters should win
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": cursor,
|
|
"sort_direction": "asc", // Different from cursor, but cursor should win
|
|
"time_field": "update_at", // Different from cursor, but cursor should win
|
|
"per_page": 100,
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "cursor parameters should take precedence (self-contained cursor)")
|
|
|
|
var result model.ReportPostListResponse
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the query used DESC order (from cursor, not request body)
|
|
// If DESC is being used, posts should be ordered by CreateAt descending
|
|
if len(result.Posts) > 1 {
|
|
postSlice := make([]*model.Post, 0, len(result.Posts))
|
|
postSlice = append(postSlice, result.Posts...)
|
|
// Sort by the expected order (desc)
|
|
sort.Slice(postSlice, func(i, j int) bool {
|
|
if postSlice[i].CreateAt == postSlice[j].CreateAt {
|
|
return postSlice[i].Id > postSlice[j].Id
|
|
}
|
|
return postSlice[i].CreateAt > postSlice[j].CreateAt
|
|
})
|
|
// Verify first post matches what we expect from DESC order
|
|
firstPost := postSlice[0]
|
|
for _, post := range result.Posts {
|
|
if post.CreateAt < baseTime {
|
|
// Found a post before cursor - verify it matches DESC expectations
|
|
require.LessOrEqual(t, post.CreateAt, baseTime, "with DESC from cursor, should get posts <= cursor time")
|
|
break
|
|
}
|
|
}
|
|
_ = firstPost // Just need to verify DESC was used
|
|
}
|
|
})
|
|
|
|
t.Run("should support update_at time field", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"time_field": "update_at",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result model.ReportPostListResponse
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result.Posts)
|
|
})
|
|
|
|
t.Run("should include deleted posts when requested", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Delete a post
|
|
deletedPost := testPosts[0]
|
|
_, err := th.SystemAdminClient.DeletePost(context.Background(), deletedPost.Id)
|
|
require.NoError(t, err)
|
|
|
|
// Request without including deleted posts
|
|
requestBody1 := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"include_deleted": false,
|
|
"per_page": 100,
|
|
}
|
|
|
|
resp1, body1 := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody1)
|
|
require.Equal(t, http.StatusOK, resp1.StatusCode)
|
|
|
|
var result1 model.ReportPostListResponse
|
|
err1 := json.Unmarshal(body1, &result1)
|
|
require.NoError(t, err1)
|
|
|
|
// Verify deleted post is not included
|
|
hasDeleted := false
|
|
for _, post := range result1.Posts {
|
|
if post.Id == deletedPost.Id {
|
|
hasDeleted = true
|
|
break
|
|
}
|
|
}
|
|
require.False(t, hasDeleted, "should not include deleted post by default")
|
|
|
|
// Request with including deleted posts
|
|
requestBody2 := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"include_deleted": true,
|
|
"per_page": 100,
|
|
}
|
|
|
|
resp2, body2 := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody2)
|
|
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
|
|
|
var result2 model.ReportPostListResponse
|
|
err2 := json.Unmarshal(body2, &result2)
|
|
require.NoError(t, err2)
|
|
|
|
// Verify deleted post is included
|
|
var deletedPostResult *model.Post
|
|
for _, post := range result2.Posts {
|
|
if post.Id == deletedPost.Id {
|
|
deletedPostResult = post
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, deletedPostResult, "should include deleted post when requested")
|
|
require.Greater(t, deletedPostResult.DeleteAt, int64(0), "post should have DeleteAt set")
|
|
})
|
|
|
|
t.Run("should exclude all system posts when requested", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Create various types of system posts with controlled timestamps
|
|
systemPostTypes := []string{
|
|
model.PostTypeHeaderChange,
|
|
model.PostTypeJoinChannel,
|
|
model.PostTypeLeaveChannel,
|
|
model.PostTypeAddToChannel,
|
|
}
|
|
|
|
for i, postType := range systemPostTypes {
|
|
systemPost := &model.Post{
|
|
ChannelId: th.BasicChannel.Id,
|
|
UserId: th.SystemAdminUser.Id,
|
|
Message: "System message",
|
|
Type: postType,
|
|
CreateAt: baseTime + (int64(20+i) * 1000), // After all test posts
|
|
UpdateAt: baseTime + (int64(20+i) * 1000),
|
|
}
|
|
_, _, appErr := th.App.CreatePost(th.Context, systemPost, th.BasicChannel, model.CreatePostFlags{})
|
|
require.Nil(t, appErr)
|
|
}
|
|
|
|
// Request with excluding all system posts
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"exclude_system_posts": true,
|
|
"per_page": 100,
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
var result model.ReportPostListResponse
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
|
|
// Verify no system posts are included (any type starting with "system_")
|
|
for _, post := range result.Posts {
|
|
require.False(t, post.IsSystemMessage(), "system posts should be excluded, found type: %s", post.Type)
|
|
}
|
|
})
|
|
|
|
t.Run("should enforce max per_page limit", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": "",
|
|
"per_page": 5000, // More than max
|
|
}
|
|
|
|
resp, body := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode, "should cap per_page to MaxReportingPerPage instead of rejecting")
|
|
|
|
// Verify it was capped to max (we can't directly check the query but the request succeeds)
|
|
var result map[string]any
|
|
err := json.Unmarshal(body, &result)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result["posts"], "should return posts with capped per_page")
|
|
})
|
|
|
|
t.Run("should validate invalid channel_id format", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Invalid ID format (not a valid 26-character ID)
|
|
requestBody := map[string]any{
|
|
"channel_id": "invalid_id",
|
|
"cursor": "",
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should reject invalid ID format")
|
|
})
|
|
|
|
t.Run("should reject tampered cursor with invalid TimeField", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Create a tampered cursor with invalid TimeField
|
|
tamperedCursor := encodeManualCursor("1", th.BasicChannel.Id, "DROP TABLE Posts", "false", "false", "asc", "1640000000000", model.NewId())
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": tamperedCursor,
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should reject cursor with invalid time_field")
|
|
})
|
|
|
|
t.Run("should reject tampered cursor with invalid SortDirection", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Create a tampered cursor with SQL injection in SortDirection
|
|
tamperedCursor := encodeManualCursor("1", th.BasicChannel.Id, "create_at", "false", "false", "ASC; DROP TABLE Posts--", "1640000000000", model.NewId())
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": tamperedCursor,
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should reject cursor with invalid sort_direction")
|
|
})
|
|
|
|
t.Run("should reject tampered cursor with invalid ChannelId", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Create a tampered cursor with invalid channel ID format
|
|
tamperedCursor := encodeManualCursor("1", "bad_id", "create_at", "false", "false", "asc", "1640000000000", model.NewId())
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": tamperedCursor,
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should reject cursor with invalid channel_id")
|
|
})
|
|
|
|
t.Run("should reject tampered cursor with invalid CursorId", func(t *testing.T) {
|
|
th.LoginSystemAdmin(t)
|
|
|
|
// Create a tampered cursor with invalid cursor ID format
|
|
tamperedCursor := encodeManualCursor("1", th.BasicChannel.Id, "create_at", "false", "false", "asc", "1640000000000", "bad_cursor_id")
|
|
|
|
requestBody := map[string]any{
|
|
"channel_id": th.BasicChannel.Id,
|
|
"cursor": tamperedCursor,
|
|
"per_page": 10,
|
|
}
|
|
|
|
resp, _ := doPostJSON(t, th.SystemAdminClient, "/api/v4/reports/posts", requestBody)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode, "should reject cursor with invalid cursor_id")
|
|
})
|
|
}
|
|
|
|
// encodeManualCursor is a test helper to create cursors with arbitrary values for tampering tests
|
|
func encodeManualCursor(version, channelId, timeField, includeDeleted, excludeSystemPosts, sortDirection, timestamp, postId string) string {
|
|
plainText := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s:%s",
|
|
version, channelId, timeField, includeDeleted, excludeSystemPosts, sortDirection, timestamp, postId)
|
|
return base64.URLEncoding.EncodeToString([]byte(plainText))
|
|
}
|