Files
baddf24ba4 [MM-69403] Push WebSocket events on job status changes (#37130)
* Add WebSocket push for job status changes, remove System Console polling

Server now broadcasts a job_updated WebSocket event whenever a job
transitions state (pending, in_progress, success, error, warning,
canceled). The webapp handles this event by updating the Redux store
immediately, so the LDAP (and any other) job table in the System Console
reflects changes in real time without a polling interval.

* Address security review: strip job Data from WS event, guard JSON.parse

- publishJobStatus now strips job.Data before broadcasting so sensitive
  job-type-specific fields (requesting_user_id, team_id, policy_id, role)
  are not exposed to WebSocket clients without permission checks; status,
  progress, and timestamps are sufficient for real-time UI updates
- handleJobUpdated wraps JSON.parse in try/catch to prevent a malformed
  payload from breaking WebSocket event handling

* Fix import/order ESLint errors for @mattermost/types/jobs

The Job type import must appear after @mattermost/types/integrations
alphabetically in both websocket_actions.ts and websocket_messages.ts.

* Add unit tests for job status WebSocket broadcasting

- Server: TestPublishJobStatus covers nil callback safety, Data stripping,
  and per-method status verification (ClaimJob→in_progress, SetJobWarning→
  warning, SetJobSuccess→success, SetJobError nil/non-nil→error,
  SetJobCanceled→canceled, SetJobPending→pending)
- Reducer: jobs.test.ts covers RECEIVED_JOB on jobsByTypeList (type absent,
  id absent, id found/updated, multi-entry list preserves other entries)
- Webapp: handleJobUpdated tests cover valid JSON dispatch and malformed
  JSON no-op

* Fix run time display by using DB-returned job for WebSocket publish

UpdateStatus now returns the full job via RETURNING (same pattern as
UpdateStatusOptimistically), so SetJob* methods can pass the DB result
to publishJobStatus. This ensures last_activity_at and start_at are
populated from the DB in the broadcasted event rather than from the
potentially-stale in-memory worker struct.

Before this change, polling masked the issue by re-fetching jobs via
REST API after completion. Without polling, the WebSocket event was
the only update and could carry zero timestamps → "--" run time.

* Return ErrNotFound from UpdateStatus when no rows matched and remove nil guards

UpdateStatus targets a specific job by ID unconditionally, so zero rows
updated means the job doesn't exist — an error, not a silent no-op.
Returning store.ErrNotFound makes callers fail loudly (rather than
silently falling back to stale in-memory data) and removes the need for
nil-guard boilerplate in every SetJob* caller.

* Re-fetch job list on terminal status to populate run time and error details

The WebSocket payload intentionally omits job Data for security, so fields
like the error message and accurate last_activity_at are only available via
the REST API. On receiving a terminal status (success/error/warning/canceled),
dispatch getJobsByType to fill in those details immediately after the status
update lands in the store.

Also set job.LastActivityAt before publishing in SetJobError's non-nil branch
so the WebSocket event carries a reasonable run time approximation even before
the re-fetch completes (UpdateOptimistically updates last_activity_at in the DB
but not the in-memory struct).

* Change UpdateOptimistically to return (*model.Job, error) via SQL RETURNING

Returns the full updated row on success; returns nil, nil when the
precondition status doesn't match (expected outcome, not an error).
Drops the manual LastActivityAt assignment in SetJobError — the DB
timestamp is now authoritative via the returned job.

* Tighten UpdateOptimistically precondition-fail assertion in storetest

The previous require.False(t, err != nil && ret != nil) would incorrectly
pass if the function returned (job, nil) instead of the expected (nil, nil).
Use explicit require.NoError + require.Nil to pin both return values.

* Scope job_updated WebSocket events to system admins only

Set ContainsSensitiveData=true so the hub only delivers the event to
connections where the user holds manage_system permission. The job
system is admin-only; regular users have no UI that consumes this event.

* Pass publish func through NewJobServer, remove SetPublish

SetPublish was called immediately after NewJobServer in its only
production call site with no initialization ordering constraint.
Passing it as a constructor parameter removes the unnecessary setter.

* Fix four gaps in job status WebSocket push coverage

- Remove ContainsSensitiveData=true from publishJobStatus: the payload
  carries no sensitive fields (Data is stripped), so sysadmins with
  delegated roles (e.g. sysconsole_read_authentication_ldap) receive
  events instead of seeing a frozen table.

- SetJobProgress now publishes in_progress events so the progress bar
  advances via WebSocket rather than staying frozen at 0%.

- RequestCancellation pending→canceled path now publishes a canceled
  event, matching the behavior of all other terminal-state transitions.

- jobsByTypeList RECEIVED_JOB reducer now prepends a newly-seen job
  when the type list is already loaded, so brand-new jobs appear
  immediately via WebSocket push without waiting for a page reload.

* Revert finding 1: restore ContainsSensitiveData=true on job WebSocket events

* Add non-admin polling fallback and fix missing WS publish in RequestCancellation

- JobTable now polls every 15s as a fallback for non-system-admin
  viewers, since job_updated WebSocket events are scoped to system
  admins only (addresses PR #37130 review comment r3462905643).
- RequestCancellation now publishes a job_updated event when a job
  transitions from in_progress to cancel_requested, matching the
  existing publish on the pending->canceled branch (addresses
  r3462940359).
- Add test coverage for both.

* Stop stripping Data from job_updated WebSocket event

publishJobStatus broadcasts are already restricted to system admins
via ContainsSensitiveData, and system admins can already read job
Data through the permissioned REST API. Stripping it here provided
no additional protection.

MM-69695 tracks scoping the broadcast to per-job-type permissions
instead of system-admin-only; that follow-up needs to reintroduce
appropriate Data filtering once the audience widens.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 22:17:29 +02:00

562 lines
14 KiB
Go

// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make store-mocks`.
package mocks
import (
model "github.com/mattermost/mattermost/server/public/model"
request "github.com/mattermost/mattermost/server/public/shared/request"
mock "github.com/stretchr/testify/mock"
)
// JobStore is an autogenerated mock type for the JobStore type
type JobStore struct {
mock.Mock
}
// Cleanup provides a mock function with given fields: expiryTime, batchSize
func (_m *JobStore) Cleanup(expiryTime int64, batchSize int) error {
ret := _m.Called(expiryTime, batchSize)
if len(ret) == 0 {
panic("no return value specified for Cleanup")
}
var r0 error
if rf, ok := ret.Get(0).(func(int64, int) error); ok {
r0 = rf(expiryTime, batchSize)
} else {
r0 = ret.Error(0)
}
return r0
}
// Delete provides a mock function with given fields: id
func (_m *JobStore) Delete(id string) (string, error) {
ret := _m.Called(id)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string) (string, error)); ok {
return rf(id)
}
if rf, ok := ret.Get(0).(func(string) string); ok {
r0 = rf(id)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Get provides a mock function with given fields: rctx, id
func (_m *JobStore) Get(rctx request.CTX, id string) (*model.Job, error) {
ret := _m.Called(rctx, id)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.Job, error)); ok {
return rf(rctx, id)
}
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.Job); ok {
r0 = rf(rctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok {
r1 = rf(rctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAllByStatus provides a mock function with given fields: rctx, status
func (_m *JobStore) GetAllByStatus(rctx request.CTX, status string) ([]*model.Job, error) {
ret := _m.Called(rctx, status)
if len(ret) == 0 {
panic("no return value specified for GetAllByStatus")
}
var r0 []*model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*model.Job, error)); ok {
return rf(rctx, status)
}
if rf, ok := ret.Get(0).(func(request.CTX, string) []*model.Job); ok {
r0 = rf(rctx, status)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok {
r1 = rf(rctx, status)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAllByType provides a mock function with given fields: rctx, jobType
func (_m *JobStore) GetAllByType(rctx request.CTX, jobType string) ([]*model.Job, error) {
ret := _m.Called(rctx, jobType)
if len(ret) == 0 {
panic("no return value specified for GetAllByType")
}
var r0 []*model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, string) ([]*model.Job, error)); ok {
return rf(rctx, jobType)
}
if rf, ok := ret.Get(0).(func(request.CTX, string) []*model.Job); ok {
r0 = rf(rctx, jobType)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string) error); ok {
r1 = rf(rctx, jobType)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAllByTypeAndStatus provides a mock function with given fields: rctx, jobType, status
func (_m *JobStore) GetAllByTypeAndStatus(rctx request.CTX, jobType string, status string) ([]*model.Job, error) {
ret := _m.Called(rctx, jobType, status)
if len(ret) == 0 {
panic("no return value specified for GetAllByTypeAndStatus")
}
var r0 []*model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, string, string) ([]*model.Job, error)); ok {
return rf(rctx, jobType, status)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string) []*model.Job); ok {
r0 = rf(rctx, jobType, status)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string) error); ok {
r1 = rf(rctx, jobType, status)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAllByTypePage provides a mock function with given fields: rctx, jobType, offset, limit
func (_m *JobStore) GetAllByTypePage(rctx request.CTX, jobType string, offset int, limit int) ([]*model.Job, error) {
ret := _m.Called(rctx, jobType, offset, limit)
if len(ret) == 0 {
panic("no return value specified for GetAllByTypePage")
}
var r0 []*model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, string, int, int) ([]*model.Job, error)); ok {
return rf(rctx, jobType, offset, limit)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, int, int) []*model.Job); ok {
r0 = rf(rctx, jobType, offset, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, int, int) error); ok {
r1 = rf(rctx, jobType, offset, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAllByTypesAndStatusesPage provides a mock function with given fields: rctx, jobType, status, offset, limit
func (_m *JobStore) GetAllByTypesAndStatusesPage(rctx request.CTX, jobType []string, status []string, offset int, limit int) ([]*model.Job, error) {
ret := _m.Called(rctx, jobType, status, offset, limit)
if len(ret) == 0 {
panic("no return value specified for GetAllByTypesAndStatusesPage")
}
var r0 []*model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, []string, []string, int, int) ([]*model.Job, error)); ok {
return rf(rctx, jobType, status, offset, limit)
}
if rf, ok := ret.Get(0).(func(request.CTX, []string, []string, int, int) []*model.Job); ok {
r0 = rf(rctx, jobType, status, offset, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, []string, []string, int, int) error); ok {
r1 = rf(rctx, jobType, status, offset, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAllByTypesPage provides a mock function with given fields: rctx, jobTypes, offset, limit
func (_m *JobStore) GetAllByTypesPage(rctx request.CTX, jobTypes []string, offset int, limit int) ([]*model.Job, error) {
ret := _m.Called(rctx, jobTypes, offset, limit)
if len(ret) == 0 {
panic("no return value specified for GetAllByTypesPage")
}
var r0 []*model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, []string, int, int) ([]*model.Job, error)); ok {
return rf(rctx, jobTypes, offset, limit)
}
if rf, ok := ret.Get(0).(func(request.CTX, []string, int, int) []*model.Job); ok {
r0 = rf(rctx, jobTypes, offset, limit)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, []string, int, int) error); ok {
r1 = rf(rctx, jobTypes, offset, limit)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetByTypeAndData provides a mock function with given fields: rctx, jobType, data, useMaster, statuses
func (_m *JobStore) GetByTypeAndData(rctx request.CTX, jobType string, data map[string]string, useMaster bool, statuses ...string) ([]*model.Job, error) {
_va := make([]interface{}, len(statuses))
for _i := range statuses {
_va[_i] = statuses[_i]
}
var _ca []interface{}
_ca = append(_ca, rctx, jobType, data, useMaster)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for GetByTypeAndData")
}
var r0 []*model.Job
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, string, map[string]string, bool, ...string) ([]*model.Job, error)); ok {
return rf(rctx, jobType, data, useMaster, statuses...)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, map[string]string, bool, ...string) []*model.Job); ok {
r0 = rf(rctx, jobType, data, useMaster, statuses...)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Job)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string, map[string]string, bool, ...string) error); ok {
r1 = rf(rctx, jobType, data, useMaster, statuses...)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetCountByStatusAndType provides a mock function with given fields: status, jobType
func (_m *JobStore) GetCountByStatusAndType(status string, jobType string) (int64, error) {
ret := _m.Called(status, jobType)
if len(ret) == 0 {
panic("no return value specified for GetCountByStatusAndType")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (int64, error)); ok {
return rf(status, jobType)
}
if rf, ok := ret.Get(0).(func(string, string) int64); ok {
r0 = rf(status, jobType)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(status, jobType)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetNewestJobByStatusAndType provides a mock function with given fields: status, jobType
func (_m *JobStore) GetNewestJobByStatusAndType(status string, jobType string) (*model.Job, error) {
ret := _m.Called(status, jobType)
if len(ret) == 0 {
panic("no return value specified for GetNewestJobByStatusAndType")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (*model.Job, error)); ok {
return rf(status, jobType)
}
if rf, ok := ret.Get(0).(func(string, string) *model.Job); ok {
r0 = rf(status, jobType)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(status, jobType)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetNewestJobByStatusesAndType provides a mock function with given fields: statuses, jobType
func (_m *JobStore) GetNewestJobByStatusesAndType(statuses []string, jobType string) (*model.Job, error) {
ret := _m.Called(statuses, jobType)
if len(ret) == 0 {
panic("no return value specified for GetNewestJobByStatusesAndType")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func([]string, string) (*model.Job, error)); ok {
return rf(statuses, jobType)
}
if rf, ok := ret.Get(0).(func([]string, string) *model.Job); ok {
r0 = rf(statuses, jobType)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func([]string, string) error); ok {
r1 = rf(statuses, jobType)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Save provides a mock function with given fields: job
func (_m *JobStore) Save(job *model.Job) (*model.Job, error) {
ret := _m.Called(job)
if len(ret) == 0 {
panic("no return value specified for Save")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func(*model.Job) (*model.Job, error)); ok {
return rf(job)
}
if rf, ok := ret.Get(0).(func(*model.Job) *model.Job); ok {
r0 = rf(job)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func(*model.Job) error); ok {
r1 = rf(job)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SaveOnce provides a mock function with given fields: job
func (_m *JobStore) SaveOnce(job *model.Job) (*model.Job, error) {
ret := _m.Called(job)
if len(ret) == 0 {
panic("no return value specified for SaveOnce")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func(*model.Job) (*model.Job, error)); ok {
return rf(job)
}
if rf, ok := ret.Get(0).(func(*model.Job) *model.Job); ok {
r0 = rf(job)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func(*model.Job) error); ok {
r1 = rf(job)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateOptimistically provides a mock function with given fields: job, currentStatus
func (_m *JobStore) UpdateOptimistically(job *model.Job, currentStatus string) (*model.Job, error) {
ret := _m.Called(job, currentStatus)
if len(ret) == 0 {
panic("no return value specified for UpdateOptimistically")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func(*model.Job, string) (*model.Job, error)); ok {
return rf(job, currentStatus)
}
if rf, ok := ret.Get(0).(func(*model.Job, string) *model.Job); ok {
r0 = rf(job, currentStatus)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func(*model.Job, string) error); ok {
r1 = rf(job, currentStatus)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateStatus provides a mock function with given fields: id, status
func (_m *JobStore) UpdateStatus(id string, status string) (*model.Job, error) {
ret := _m.Called(id, status)
if len(ret) == 0 {
panic("no return value specified for UpdateStatus")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (*model.Job, error)); ok {
return rf(id, status)
}
if rf, ok := ret.Get(0).(func(string, string) *model.Job); ok {
r0 = rf(id, status)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(id, status)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateStatusOptimistically provides a mock function with given fields: id, currentStatus, newStatus
func (_m *JobStore) UpdateStatusOptimistically(id string, currentStatus string, newStatus string) (*model.Job, error) {
ret := _m.Called(id, currentStatus, newStatus)
if len(ret) == 0 {
panic("no return value specified for UpdateStatusOptimistically")
}
var r0 *model.Job
var r1 error
if rf, ok := ret.Get(0).(func(string, string, string) (*model.Job, error)); ok {
return rf(id, currentStatus, newStatus)
}
if rf, ok := ret.Get(0).(func(string, string, string) *model.Job); ok {
r0 = rf(id, currentStatus, newStatus)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Job)
}
}
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
r1 = rf(id, currentStatus, newStatus)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewJobStore creates a new instance of JobStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewJobStore(t interface {
mock.TestingT
Cleanup(func())
}) *JobStore {
mock := &JobStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}