Files
mattermost/server/channels/api4/job_test.go
T
Ben SchumacherandClaude Sonnet 5 cce485f605 [MM-69561] Add ability to rotate (regenerate) Personal Access Tokens (#37295)
* MM-69561: Add ability to rotate (regenerate) Personal Access Tokens

- Add UpdateTokenRotate to UserAccessTokenStore interface and implement
  in sqlstore: deletes sessions on the old secret, then updates the
  token row with the new secret and expiry in one transaction
- Regenerate store retrylayer, timerlayer, and mocks
- Add RotateUserAccessToken app method: validates expiry (bot-exempt),
  captures old session for cache eviction, generates new secret, and
  sends a notification email
- Register POST /api/v4/users/tokens/rotate handler with full permission
  checks (create_user_access_token + edit_other_users + manage_system
  for sysadmin targets); rejects OAuth sessions and disabled tokens
- Add RotateUserAccessToken to the Go client (client4.go)
- Add storetest covering secret rotation and old-session cleanup
- Add API4 tests: happy path, permission denials, OAuth rejection, and
  max-lifetime enforcement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Add dedicated rotate email and i18n strings

- Add SendUserAccessTokenRotatedEmail (subject/body distinct from the
  'added' email so users aren't confused by a rotation event)
- Add SendUserAccessTokenRotatedEmail to ServiceInterface + mock
- Add en.json strings for the rotate email and the two new error ids
  (rotate.app_error, disabled_token.app_error)
- Switch RotateUserAccessToken to call SendUserAccessTokenRotatedEmail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Add mmctl token rotate subcommand

- Add RotateUserAccessToken to the mmctl Client interface and mock
- Add 'mmctl token rotate <token-id> [--expires-in <duration>]' command
  reusing the existing resolveTokenExpiry/parseExpiresIn helpers from
  'generate'; prints the new secret once on success
- Add unit tests: happy path, --expires-in passed through, server error,
  invalid --expires-in

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Regenerate mmctl docs for token rotate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Add API docs for POST /users/tokens/rotate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Fix i18n string ordering after extract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Add missing API4 test cases for token rotate

Cover the three untested access-control branches flagged by the test
analysis bot:
- Rotating a disabled token returns 400
- Non-system-admin rotating a sysadmin's token returns 403
- Rotating a remote user's token returns 403

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Address review comments

- Fix handler authorization order: check SessionHasPermissionToUserOrBot
  and manage_system before IsRemote/IsActive to avoid leaking token state
  to unauthorized callers; matches revokeUserAccessToken/disableUserAccessToken
- Fix API docs minimum server version: 10.8 -> 10.10

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* MM-69561: Restore i18n strings accidentally deleted by extract

The earlier i18n-extract run stripped ~164 unrelated translation keys
(mostly enterprise-only strings like app.pap.* and api.ldap.*) because
the enterprise codebase isn't present in this checkout, so the
extractor treated them as unused. Restore them while keeping the 5
new keys added for token rotation.

* comment

* [MM-69561] Add webapp Regenerate option for Personal Access Tokens

Adds a "Regenerate" link to Account Settings > Security > Personal
Access Tokens that calls the POST /users/tokens/rotate endpoint added
in the server-side rotate PAT work. Regenerating shows a confirmation
modal naming the token, then reveals the new secret via the existing
one-time-copy flow used for token creation.

- webapp Client4.rotateUserAccessToken
- mattermost-redux rotateUserAccessToken action
- Regenerate link/confirm modal/reveal flow in user_access_token_section
- i18n strings
- Playwright e2e coverage

* Fix regenerate PAT e2e test: confirm modal is not nested in the Profile dialog

ConfirmModal renders via react-bootstrap's Modal, which portals to
document.body as a sibling of the Profile dialog rather than a
descendant, so it must be located via #confirmModal on the page
instead of scoped to the Profile dialog locator.

* Let users pick a new expiry when regenerating a Personal Access Token

Previously, regenerating a token always called rotateUserAccessToken
with no expiresAt, so the rotated secret never expired even if the
original token did. The Regenerate confirmation modal now includes the
same expiry picker used by token creation (extracted into a shared
renderExpiryPicker helper), enforces MaximumPersonalAccessTokenLifetimeDays
the same way, and disables the confirm button until a valid expiry is
selected.

* Scope the red background in the Regenerate modal to the warning text only

The confirmation question, expiry picker, and its hints were sitting
inside the same alert-danger box as the warning, making the whole
modal read as an error. Only the warning paragraph keeps the red
background now.

* Move the regenerate confirmation question below the expiry picker

* Align rotate-token wording with UI: use 'regenerate/regenerated'

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [MM-69561] rename pat_expiry_notify job to notify_expiring_access_tokens

Unifies naming with the sibling cleanup_expired_access_tokens job and
fixes the "expiry" vs "expiring" ambiguity: this job warns about tokens
approaching expiry, not ones that have already expired. Safe to rename
outright since the job hasn't shipped yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [MM-69561] remove dead session lookup from EnableUserAccessToken

The GetSessionContext call and its result were never used: both branches
returned nil regardless. Leftover from mirroring DisableUserAccessToken's
shape, which does use its session (to revoke it) unlike Enable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [MM-69561] rename remaining PAT identifiers to match AccessToken convention

The job-level rename (pat_expiry_notify -> notify_expiring_access_tokens)
left the app-layer function and its helpers using the old PAT/
PersonalAccessToken naming. Rename them to match:

- NotifyPersonalAccessTokensExpiring -> NotifyExpiringAccessTokens
- patExpiryBucket -> accessTokenExpiryBucket
- sendPATExpiryNotification -> sendAccessTokenExpiryNotification
- patExpiryNotifyBatchLimit -> expiringAccessTokenBatchLimit
- patExpiryThresholds -> expiringAccessTokenThresholds
- maxPersonalAccessTokenExpiry -> maxUserAccessTokenExpiry

Also reword the package doc on notify_expiring_access_tokens, which no
longer needs to explain a PAT/UserAccessToken naming split now that the
app-layer method matches the job name.

Pure rename, no behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 23:08:19 +02:00

841 lines
26 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestCreateJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
job := &model.Job{
Type: model.JobTypeActiveUsers,
Data: map[string]string{
"thing": "stuff",
},
}
t.Run("valid job as user without permissions", func(t *testing.T) {
_, resp, err := th.SystemManagerClient.CreateJob(context.Background(), job)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("valid job as user with permissions", func(t *testing.T) {
received, _, err := th.SystemAdminClient.CreateJob(context.Background(), job)
require.NoError(t, err)
defer func() {
result, appErr := th.App.Srv().Store().Job().Delete(received.Id)
require.NoErrorf(t, appErr, "Failed to delete job (result: %v): %v", result, appErr)
}()
})
t.Run("invalid job type as user without permissions", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.CreateJob(context.Background(), &model.Job{Type: model.NewId()})
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
}
func TestCreateNotifyExpiringAccessTokensJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
job := &model.Job{Type: model.JobTypeNotifyExpiringAccessTokens}
t.Run("forbidden without manage_jobs permission", func(t *testing.T) {
_, resp, err := th.SystemManagerClient.CreateJob(context.Background(), job)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("allowed as system admin", func(t *testing.T) {
received, _, err := th.SystemAdminClient.CreateJob(context.Background(), job)
require.NoError(t, err)
defer func() {
result, appErr := th.App.Srv().Store().Job().Delete(received.Id)
require.NoErrorf(t, appErr, "Failed to delete job (result: %v): %v", result, appErr)
}()
require.Equal(t, model.JobTypeNotifyExpiringAccessTokens, received.Type)
})
}
func TestGetJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
job := &model.Job{
Id: model.NewId(),
Status: model.JobStatusPending,
Type: model.JobTypeMessageExport,
}
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
result, appErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, appErr, "Failed to delete job (result: %v)", result)
}()
received, _, err := th.SystemAdminClient.GetJob(context.Background(), job.Id)
require.NoError(t, err)
require.Equal(t, job.Id, received.Id, "incorrect job received")
require.Equal(t, job.Status, received.Status, "incorrect job received")
_, resp, err := th.SystemAdminClient.GetJob(context.Background(), "1234")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
_, resp, err = th.Client.GetJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemAdminClient.GetJob(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestGetJobs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
jobType := model.JobTypeDataRetention
t0 := model.GetMillis()
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
CreateAt: t0 + 1,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: t0,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: t0 + 2,
},
{
Id: model.NewId(),
Type: model.JobTypeLdapSync,
CreateAt: t0 + 3,
Status: model.JobStatusPending,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobId string) {
result, appErr := th.App.Srv().Store().Job().Delete(jobId)
require.NoError(t, appErr, "Failed to delete job (result: %v)", result)
}(job.Id)
}
t.Run("Get 2 jobs", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), "", "", 0, 2)
require.NoError(t, err)
require.Len(t, received, 2, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received newest job first")
require.Equal(t, jobs[2].Id, received[1].Id, "should've received second newest job second")
})
t.Run("Get oldest job using paging", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), "", "", 1, 3)
require.NoError(t, err)
require.Equal(t, jobs[1].Id, received[0].Id, "should've received oldest job last")
})
t.Run("Return error fetching job without permissions", func(t *testing.T) {
_, resp, err := th.Client.GetJobs(context.Background(), "", "", 0, 60)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("Get job by type", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), model.JobTypeLdapSync, "", 0, 3)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received the ldap sync job")
})
t.Run("Get job by status", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), "", model.JobStatusPending, 0, 3)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received the ldap sync job")
})
t.Run("Get job by type and status", func(t *testing.T) {
received, _, err := th.SystemAdminClient.GetJobs(context.Background(), model.JobTypeLdapSync, model.JobStatusPending, 0, 3)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[3].Id, received[0].Id, "should've received the ldap sync job")
})
t.Run("Return 400 for invalid status", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.GetJobs(context.Background(), "", "not_a_valid_status", 0, 60)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
}
func TestGetJobsByType(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
jobType := model.JobTypeDataRetention
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
CreateAt: 1000,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: 999,
},
{
Id: model.NewId(),
Type: jobType,
CreateAt: 1001,
},
{
Id: model.NewId(),
Type: model.NewId(),
CreateAt: 1002,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobId string) {
result, appErr := th.App.Srv().Store().Job().Delete(jobId)
require.NoError(t, appErr, "Failed to delete job (result: %v)", result)
}(job.Id)
}
received, _, err := th.SystemAdminClient.GetJobsByType(context.Background(), jobType, 0, 2)
require.NoError(t, err)
require.Len(t, received, 2, "received wrong number of jobs")
require.Equal(t, jobs[2].Id, received[0].Id, "should've received newest job first")
require.Equal(t, jobs[0].Id, received[1].Id, "should've received second newest job second")
received, _, err = th.SystemAdminClient.GetJobsByType(context.Background(), jobType, 1, 2)
require.NoError(t, err)
require.Len(t, received, 1, "received wrong number of jobs")
require.Equal(t, jobs[1].Id, received[0].Id, "should've received oldest job last")
_, resp, err := th.SystemAdminClient.GetJobsByType(context.Background(), "", 0, 60)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = th.SystemAdminClient.GetJobsByType(context.Background(), strings.Repeat("a", 33), 0, 60)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
_, resp, err = th.Client.GetJobsByType(context.Background(), jobType, 0, 60)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, _, err = th.SystemManagerClient.GetJobsByType(context.Background(), model.JobTypeElasticsearchPostIndexing, 0, 60)
require.NoError(t, err)
}
func TestGetJobsByTypeWithPolicyIDFilter(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.LoginSystemManager(t)
policyID := model.NewId()
otherPolicyID := model.NewId()
t0 := model.GetMillis()
jobs := []*model.Job{
{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
CreateAt: t0,
Data: map[string]string{"policy_id": policyID},
},
{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
CreateAt: t0 + 1,
Data: map[string]string{"policy_id": policyID},
},
{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
CreateAt: t0 + 2,
Data: map[string]string{"policy_id": otherPolicyID},
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobID string) {
_, appErr := th.App.Srv().Store().Job().Delete(jobID)
require.NoError(t, appErr, "Failed to delete job %s", jobID)
}(job.Id)
}
t.Run("policy_id filter returns only matching jobs", func(t *testing.T) {
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Len(t, received, 2)
// Newest first
require.Equal(t, jobs[1].Id, received[0].Id)
require.Equal(t, jobs[0].Id, received[1].Id)
})
t.Run("policy_id filter excludes other policies", func(t *testing.T) {
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+otherPolicyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Len(t, received, 1)
require.Equal(t, jobs[2].Id, received[0].Id)
})
t.Run("policy_id filter on non-access_control_sync type is ignored", func(t *testing.T) {
// Save a data-retention job with a policy_id field (unusual, but proves the filter is ignored)
drJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeDataRetention,
CreateAt: t0 + 3,
Data: map[string]string{"policy_id": policyID},
}
_, err := th.App.Srv().Store().Job().Save(drJob)
require.NoError(t, err)
defer func() {
_, appErr := th.App.Srv().Store().Job().Delete(drJob.Id)
require.NoError(t, appErr)
}()
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeDataRetention+"?page=0&per_page=60&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
// policy_id is ignored for non-access_control_sync; all data-retention jobs are returned
ids := make([]string, len(received))
for i, j := range received {
ids[i] = j.Id
}
require.Contains(t, ids, drJob.Id)
})
t.Run("policy_id filter requires system admin permission", func(t *testing.T) {
// SessionHasPermissionToReadJob for JobTypeAccessControlSync already requires
// PermissionManageSystem (see app/job.go), so the policyID guard in getJobsByType
// acts as defence-in-depth. Use SystemManagerClient — a role that has many admin
// privileges but intentionally lacks PermissionManageSystem — to verify that any
// caller without manage_system is denied (403) at the read-job gate before the
// policyID branch is even reached.
resp, err := th.SystemManagerClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+policyID,
"",
)
require.Error(t, err)
require.Equal(t, 403, resp.StatusCode)
resp.Body.Close()
})
t.Run("without policy_id returns all access_control_sync jobs", func(t *testing.T) {
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60",
"",
)
require.NoError(t, err)
defer resp.Body.Close()
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
ids := make([]string, len(received))
for i, j := range received {
ids[i] = j.Id
}
require.Contains(t, ids, jobs[0].Id)
require.Contains(t, ids, jobs[1].Id)
require.Contains(t, ids, jobs[2].Id)
})
t.Run("policy_id with no matching jobs returns empty list not error", func(t *testing.T) {
unknownPolicyID := model.NewId()
resp, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=60&policy_id="+unknownPolicyID,
"",
)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
var received []*model.Job
require.NoError(t, json.NewDecoder(resp.Body).Decode(&received))
require.Empty(t, received)
})
t.Run("policy_id filter respects page and per_page pagination", func(t *testing.T) {
// Two jobs match policyID (jobs[0] at t0, jobs[1] at t0+1). Sorted newest-first,
// so page=0,per_page=1 → jobs[1]; page=1,per_page=1 → jobs[0]; page=2 → empty.
resp0, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=0&per_page=1&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp0.Body.Close()
var page0 []*model.Job
require.NoError(t, json.NewDecoder(resp0.Body).Decode(&page0))
require.Len(t, page0, 1)
require.Equal(t, jobs[1].Id, page0[0].Id, "page 0 should be the newest job")
resp1, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=1&per_page=1&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp1.Body.Close()
var page1 []*model.Job
require.NoError(t, json.NewDecoder(resp1.Body).Decode(&page1))
require.Len(t, page1, 1)
require.Equal(t, jobs[0].Id, page1[0].Id, "page 1 should be the older job")
resp2, err := th.SystemAdminClient.DoAPIGet(
context.Background(),
"/jobs/type/"+model.JobTypeAccessControlSync+"?page=2&per_page=1&policy_id="+policyID,
"",
)
require.NoError(t, err)
defer resp2.Body.Close()
var page2 []*model.Job
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&page2))
require.Empty(t, page2, "page beyond last should be empty")
})
}
func TestGetJobsByType_TeamAdminAccessControlSync(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.AddPermissionToRole(t, model.PermissionManageTeamAccessRules.Id, model.TeamAdminRoleId)
teamJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"team_id": th.BasicTeam.Id, "policy_id": "p1"},
}
otherTeamJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"team_id": model.NewId(), "policy_id": "p2"},
}
systemJob := &model.Job{
Id: model.NewId(),
Type: model.JobTypeAccessControlSync,
Status: model.JobStatusSuccess,
CreateAt: model.GetMillis(),
Data: map[string]string{"policy_id": "p3"},
}
for _, job := range []*model.Job{teamJob, otherTeamJob, systemJob} {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(id string) {
_, _ = th.App.Srv().Store().Job().Delete(id)
}(job.Id)
}
t.Run("team admin can see only their team's sync jobs", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
jobs, _, err := th.Client.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, teamJob.Id, jobs[0].Id)
})
t.Run("team admin cannot see sync jobs without team_id", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
_, resp, err := th.Client.GetJobsByType(context.Background(), model.JobTypeAccessControlSync, 0, 60)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("team admin cannot read non-sync job types with team_id", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
_, resp, err := th.Client.GetJobsByTypeForTeam(context.Background(), model.JobTypeDataRetention, 0, 60, th.BasicTeam.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("system admin sees all sync jobs without team_id filter", func(t *testing.T) {
jobs, _, err := th.SystemAdminClient.GetJobsByType(context.Background(), model.JobTypeAccessControlSync, 0, 60)
require.NoError(t, err)
require.GreaterOrEqual(t, len(jobs), 3)
})
t.Run("system admin with team_id filter sees only that team's jobs", func(t *testing.T) {
jobs, _, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, jobs, 1)
require.Equal(t, teamJob.Id, jobs[0].Id)
})
t.Run("returns empty when no team-scoped jobs exist for that team", func(t *testing.T) {
jobs, _, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, model.NewId())
require.NoError(t, err)
require.Empty(t, jobs)
})
t.Run("pagination works correctly for team-scoped jobs", func(t *testing.T) {
// page=0 returns the job, page=1 returns empty (not the same page again)
jobs, _, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 1, th.BasicTeam.Id)
require.NoError(t, err)
require.Len(t, jobs, 1)
jobs, _, err = th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 1, 1, th.BasicTeam.Id)
require.NoError(t, err)
require.Empty(t, jobs)
})
t.Run("team admin cannot query jobs for a team they are not admin of", func(t *testing.T) {
th.LoginTeamAdmin(t)
defer th.LoginBasic(t)
otherTeamID := model.NewId()
_, resp, err := th.Client.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, otherTeamID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("malformed team_id returns 400 instead of silently dropping the filter", func(t *testing.T) {
// "not-a-valid-id" is not a 26-character alphanum Mattermost ID.
_, resp, err := th.SystemAdminClient.GetJobsByTypeForTeam(context.Background(), model.JobTypeAccessControlSync, 0, 60, "not-a-valid-id")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
}
func TestDownloadJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.LoginSystemManager(t)
jobName := model.NewId()
job := &model.Job{
Id: jobName,
Type: model.JobTypeMessageExport,
Data: map[string]string{
"export_type": "csv",
},
Status: model.JobStatusSuccess,
}
// DownloadExportResults is not set to true so we should get a not implemented error status
_, resp, err := th.Client.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.DownloadExportResults = true
})
// Normal user cannot download the results of these job (non-existent job)
_, resp, err = th.Client.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// System admin trying to download the results of a non-existent job
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Here we have a job that exist in our database but the results do not exist therefore when we try to download the results
// as a system admin, we should get a not found status.
_, err = th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
_, delErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, delErr, "Failed to delete job %s", job.Id)
}()
filePath := filepath.Join(*th.App.Config().FileSettings.Directory, "export/"+job.Id+"/testdat.txt")
err = os.MkdirAll(filepath.Dir(filePath), 0770)
require.NoError(t, err)
_, createErr := os.Create(filePath)
require.NoError(t, createErr)
// Normal user cannot download the results of these job (not the right permission)
_, resp, err = th.Client.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemManagerClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// System manager with default permissions cannot download the results of these job (Doesn't have correct permissions)
_, resp, err = th.SystemManagerClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
job.Data["is_downloadable"] = "true"
updatedJob, err := th.App.Srv().Store().Job().UpdateOptimistically(job, model.JobStatusSuccess)
require.NotNil(t, updatedJob)
require.NoError(t, err)
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Now we stub the results of the job into the same directory and try to download it again
// This time we should successfully retrieve the results without any error
filePath = filepath.Join(*th.App.Config().FileSettings.Directory, "export/"+job.Id+".zip")
err = os.MkdirAll(filepath.Dir(filePath), 0770)
require.NoError(t, err)
_, createErr = os.Create(filePath)
require.NoError(t, createErr)
_, _, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.NoError(t, err)
// Here we are creating a new job which doesn't have type of message export
jobName = model.NewId()
job = &model.Job{
Id: jobName,
Type: model.JobTypeCloud,
Data: map[string]string{
"export_type": "csv",
},
Status: model.JobStatusSuccess,
}
_, err = th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
_, delErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, delErr, "Failed to delete job %s", job.Id)
}()
// System admin shouldn't be able to download since the job type is not message export
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
// Test the case where export_dir is not valid
jobName = model.NewId()
job = &model.Job{
Id: jobName,
Type: model.JobTypeMessageExport,
Data: map[string]string{
"export_type": "csv",
"is_downloadable": "true",
"export_dir": "/bad/absolute/path",
},
Status: model.JobStatusSuccess,
}
_, err = th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func() {
_, delErr := th.App.Srv().Store().Job().Delete(job.Id)
require.NoError(t, delErr, "Failed to delete job %s", job.Id)
}()
_, resp, err = th.SystemAdminClient.DownloadJob(context.Background(), job.Id)
require.Error(t, err)
require.EqualError(t, err, "Unable to download this job")
CheckNotFoundStatus(t, resp)
}
func TestCancelJob(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
jobType := model.JobTypeMessageExport
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusPending,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusInProgress,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusSuccess,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobId string) {
_, delErr := th.App.Srv().Store().Job().Delete(jobId)
require.NoError(t, delErr, "Failed to delete job %s", jobId)
}(job.Id)
}
resp, err := th.Client.CancelJob(context.Background(), jobs[0].Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, err = th.SystemAdminClient.CancelJob(context.Background(), jobs[0].Id)
require.NoError(t, err)
_, err = th.SystemAdminClient.CancelJob(context.Background(), jobs[1].Id)
require.NoError(t, err)
resp, err = th.SystemAdminClient.CancelJob(context.Background(), jobs[2].Id)
require.Error(t, err)
CheckInternalErrorStatus(t, resp)
resp, err = th.SystemAdminClient.CancelJob(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestUpdateJobStatus(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
jobType := model.JobTypeDataRetention
jobs := []*model.Job{
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusPending,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusInProgress,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusSuccess,
},
{
Id: model.NewId(),
Type: jobType,
Status: model.JobStatusPending,
},
}
for _, job := range jobs {
_, err := th.App.Srv().Store().Job().Save(job)
require.NoError(t, err)
defer func(jobID string) {
_, delErr := th.App.Srv().Store().Job().Delete(jobID)
require.NoError(t, delErr, "Failed to delete job %s", jobID)
}(job.Id)
}
t.Run("Fail to update job status without permission", func(t *testing.T) {
resp, err := th.Client.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCancelRequested, false)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("Change a pending job to cancel requested without force with sysadmin client", func(t *testing.T) {
_, err := th.SystemAdminClient.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCancelRequested, false)
require.NoError(t, err)
})
t.Run("Change a pending job to cancel requested without force with local client", func(t *testing.T) {
_, err := th.LocalClient.UpdateJobStatus(context.Background(), jobs[3].Id, model.JobStatusCancelRequested, false)
require.NoError(t, err)
})
t.Run("Fail to change a pending job to canceled without force", func(t *testing.T) {
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resp, err := client.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCanceled, false)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
})
t.Run("Change a pending job to canceled with force", func(t *testing.T) {
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err := client.UpdateJobStatus(context.Background(), jobs[0].Id, model.JobStatusCanceled, true)
require.NoError(t, err)
})
})
}