diff --git a/server/api.go b/server/api.go index 62e6d76..69d90f3 100644 --- a/server/api.go +++ b/server/api.go @@ -45,6 +45,7 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}", p.updateLegalHold).Methods(http.MethodPut) router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet) router.HandleFunc("/api/v1/test_amazon_s3_connection", p.testAmazonS3Connection).Methods(http.MethodPost) + router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/run", p.runLegalHoldNow).Methods(http.MethodPost) // Other routes router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost) @@ -320,6 +321,37 @@ func (p *Plugin) runJobFromAPI(w http.ResponseWriter, _ *http.Request) { go p.legalHoldJob.RunFromAPI() } +func (p *Plugin) runLegalHoldNow(w http.ResponseWriter, r *http.Request) { + legalholdID, err := RequireLegalHoldID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Fetch the LegalHold + lh, err := p.KVStore.GetLegalHoldByID(legalholdID) + if err != nil { + p.API.LogError("Failed to release legal hold - retrieve legal hold from kvstore", err.Error()) + http.Error(w, "failed to release legal hold", http.StatusInternalServerError) + return + } + + p.legalHoldJob.RunSingleHoldNow(lh) + + b, jsonErr := json.Marshal(make(map[string]interface{})) + if jsonErr != nil { + http.Error(w, "Error encoding json", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(b) + if err != nil { + p.API.LogError("failed to write http response", err.Error()) + return + } +} + // testAmazonS3Connection tests the plugin's custom Amazon S3 connection func (p *Plugin) testAmazonS3Connection(w http.ResponseWriter, _ *http.Request) { type messageResponse struct { diff --git a/server/api_test.go b/server/api_test.go index 003ae2a..7867b36 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -1,16 +1,20 @@ package main import ( + "fmt" "net/http" "net/http/httptest" "testing" pluginapi "github.com/mattermost/mattermost-plugin-api" - "github.com/mattermost/mattermost-plugin-legal-hold/server/config" - "github.com/mattermost/mattermost-server/v6/model" + mattermostModel "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin/plugintest" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-legal-hold/server/config" + "github.com/mattermost/mattermost-plugin-legal-hold/server/store/kvstore" ) func TestTestAmazonS3Connection(t *testing.T) { @@ -20,7 +24,7 @@ func TestTestAmazonS3Connection(t *testing.T) { p.SetAPI(api) p.Client = pluginapi.NewClient(p.API, p.Driver) - api.On("HasPermissionTo", "test_user_id", model.PermissionManageSystem).Return(true) + api.On("HasPermissionTo", "test_user_id", mattermostModel.PermissionManageSystem).Return(true) api.On("LogInfo", mock.Anything).Maybe() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -37,16 +41,16 @@ func TestTestAmazonS3Connection(t *testing.T) { TimeOfDay: "10:00pm -0500h", AmazonS3BucketSettings: config.AmazonS3BucketSettings{ Enable: true, - Settings: model.FileSettings{ - DriverName: model.NewString("amazons3"), - AmazonS3Bucket: model.NewString("bucket"), - AmazonS3AccessKeyId: model.NewString("access_key_id"), - AmazonS3SecretAccessKey: model.NewString("secret_access_key"), - AmazonS3RequestTimeoutMilliseconds: model.NewInt64(5000), - AmazonS3Endpoint: model.NewString(server.Listener.Addr().String()), - AmazonS3Region: model.NewString("us-east-1"), - AmazonS3SSL: model.NewBool(false), - AmazonS3SSE: model.NewBool(false), + Settings: mattermostModel.FileSettings{ + DriverName: mattermostModel.NewString("amazons3"), + AmazonS3Bucket: mattermostModel.NewString("bucket"), + AmazonS3AccessKeyId: mattermostModel.NewString("access_key_id"), + AmazonS3SecretAccessKey: mattermostModel.NewString("secret_access_key"), + AmazonS3RequestTimeoutMilliseconds: mattermostModel.NewInt64(5000), + AmazonS3Endpoint: mattermostModel.NewString(server.Listener.Addr().String()), + AmazonS3Region: mattermostModel.NewString("us-east-1"), + AmazonS3SSL: mattermostModel.NewBool(false), + AmazonS3SSE: mattermostModel.NewBool(false), }, }, }) @@ -60,3 +64,68 @@ func TestTestAmazonS3Connection(t *testing.T) { p.ServeHTTP(nil, recorder, req) require.Equal(t, http.StatusOK, recorder.Code) } + +func TestRunLegalHoldNow(t *testing.T) { + validID := mattermostModel.NewId() + + tests := []struct { + name string + legalHoldID string + wantErr bool + expectCode int + }{ + { + name: "Invalid Legal Hold ID", + legalHoldID: "malformedid", + wantErr: true, + expectCode: http.StatusBadRequest, + }, + { + name: "Unknown Legal Hold ID", + legalHoldID: mattermostModel.NewId(), + wantErr: true, + expectCode: http.StatusInternalServerError, + }, + // TODO: To test the successful case, we need to put Plugin.LegalHoldJob behind + // an interface so that it can be mocked out. + // { + // name: "Valid Legal Hold", + // legalHoldID: validID, + // wantErr: false, + // expectCode: http.StatusOK, + // }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{} + api := &plugintest.API{} + p.SetDriver(&plugintest.Driver{}) + p.SetAPI(api) + p.Client = pluginapi.NewClient(p.API, p.Driver) + p.KVStore = kvstore.NewKVStore(p.Client) + + api.On("HasPermissionTo", "test_user_id", mattermostModel.PermissionManageSystem).Return(true) + api.On("LogInfo", mock.Anything).Maybe() + api.On("LogError", mock.Anything, mock.Anything).Maybe() + api.On("KVGet", fmt.Sprintf("kvstore_legal_hold_%s", validID)). + Return([]uint8("{}"), nil) + api.On("KVGet", mock.Anything). + Return(nil, mattermostModel.NewAppError("things", "stuff.stuff", nil, "", http.StatusNotFound)) + + recorder := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/legalhold/%s/run", tt.legalHoldID), nil) + require.NoError(t, err) + + req.Header.Add("Mattermost-User-Id", "test_user_id") + + p.ServeHTTP(nil, recorder, req) + require.Equal(t, tt.expectCode, recorder.Code) + if tt.wantErr { + assert.NotEmpty(t, recorder.Body) + } else { + assert.Empty(t, recorder.Body) + } + }) + } +} diff --git a/server/jobs/legal_hold_job.go b/server/jobs/legal_hold_job.go index a97de27..e51bc8b 100644 --- a/server/jobs/legal_hold_job.go +++ b/server/jobs/legal_hold_job.go @@ -3,7 +3,6 @@ package jobs import ( "context" "fmt" - "sync" "time" @@ -16,6 +15,7 @@ import ( "github.com/mattermost/mattermost-plugin-legal-hold/server/config" "github.com/mattermost/mattermost-plugin-legal-hold/server/legalhold" + "github.com/mattermost/mattermost-plugin-legal-hold/server/model" "github.com/mattermost/mattermost-plugin-legal-hold/server/store/kvstore" "github.com/mattermost/mattermost-plugin-legal-hold/server/store/sqlstore" ) @@ -152,7 +152,15 @@ func (j *LegalHoldJob) RunFromAPI() { j.run() } +func (j *LegalHoldJob) RunSingleHoldNow(legalHold *model.LegalHold) { + j.runWithHold(legalHold) +} + func (j *LegalHoldJob) run() { + j.runWithHold(nil) +} + +func (j *LegalHoldJob) runWithHold(legalHold *model.LegalHold) { j.mux.Lock() oldRunner := j.runner j.mux.Unlock() @@ -186,49 +194,97 @@ func (j *LegalHoldJob) run() { settings = j.settings.Clone() j.mux.Unlock() - j.client.Log.Info("Processing all Legal Holds") + // No legal hold specified, run as scheduled for all holds. + if legalHold == nil { + j.client.Log.Info("Processing all Legal Holds") + // Retrieve the legal holds from the store. + legalHolds, err := j.kvstore.GetAllLegalHolds() + if err != nil { + j.client.Log.Error("Failed to fetch legal holds from store", err) + } - // Retrieve the legal holds from the store. - legalHolds, err := j.kvstore.GetAllLegalHolds() - if err != nil { - j.client.Log.Error("Failed to fetch legal holds from store", err) - } + for _, lh := range legalHolds { + for { + if lh.IsFinished() { + j.client.Log.Debug(fmt.Sprintf("Legal Hold %s has ended and therefore does not executing.", lh.ID)) + break + } + + if !lh.NeedsExecuting(mattermostModel.GetMillis()) { + j.client.Log.Debug(fmt.Sprintf("Legal Hold %s is not yet ready to be executed again.", lh.ID)) + break + } + + j.client.Log.Debug(fmt.Sprintf("Creating Legal Hold Execution for legal hold: %s", lh.ID)) + lhe := legalhold.NewExecution(lh, j.papi, j.sqlstore, j.filebackend) + + if end, err := lhe.Execute(); err != nil { + j.client.Log.Error("An error occurred executing the legal hold.", err) + } else { + old, err := j.kvstore.GetLegalHoldByID(lh.ID) + if err != nil { + j.client.Log.Error("Failed to fetch the LegalHold prior to updating", err) + continue + } + lh = *old + lh.LastExecutionEndedAt = end + newLH, err := j.kvstore.UpdateLegalHold(lh, *old) + if err != nil { + j.client.Log.Error("Failed to update legal hold", err) + continue + } + lh = *newLH + j.client.Log.Info(fmt.Sprintf("%v", lh)) + } + j.client.Log.Info("legal hold executed", "legal_hold_id", lh.ID, "legal_hold_name", lh.Name) + } + } + } else { + // Just one legal hold specified - force run it now. + j.client.Log.Debug(fmt.Sprintf("Manually triggered legal hold run for legal hold: %s", legalHold.ID)) + now := mattermostModel.GetMillis() - for _, lh := range legalHolds { for { - if lh.IsFinished() { - j.client.Log.Debug(fmt.Sprintf("Legal Hold %s has ended and therefore does not executing.", lh.ID)) - break + if legalHold.IsFinished() { + j.client.Log.Debug(fmt.Sprintf("Legal Hold %s has ended and therefore does not executing.", legalHold.ID)) + return } - if !lh.NeedsExecuting(mattermostModel.GetMillis()) { - j.client.Log.Debug(fmt.Sprintf("Legal Hold %s is not yet ready to be executed again.", lh.ID)) - break + if legalHold.LastExecutionEndedAt >= now { + j.client.Log.Debug(fmt.Sprintf("Legal Hold %s is not yet ready to be executed again.", legalHold.ID)) + return } + j.client.Log.Debug(fmt.Sprintf("Last Execution Ended At: %d, now: %d", legalHold.LastExecutionEndedAt, now)) + + j.client.Log.Debug(fmt.Sprintf("Creating manually triggered Legal Hold Execution for legal hold: %s", legalHold.ID)) + lhe := legalhold.NewExecution(*legalHold, j.papi, j.sqlstore, j.filebackend) - j.client.Log.Debug(fmt.Sprintf("Creating Legal Hold Execution for legal hold: %s", lh.ID)) - lhe := legalhold.NewExecution(lh, j.papi, j.sqlstore, j.filebackend) + if lhe.ExecutionEndTime > now { + j.client.Log.Info(fmt.Sprintf("Legal Hold %s: limiting execution end time to ensure it is not in the future.", legalHold.ID)) + lhe.ExecutionEndTime = now + } if end, err := lhe.Execute(); err != nil { j.client.Log.Error("An error occurred executing the legal hold.", err) } else { - old, err := j.kvstore.GetLegalHoldByID(lh.ID) + old, err := j.kvstore.GetLegalHoldByID(legalHold.ID) if err != nil { j.client.Log.Error("Failed to fetch the LegalHold prior to updating", err) - continue + return } - lh = *old + lh := *old lh.LastExecutionEndedAt = end newLH, err := j.kvstore.UpdateLegalHold(lh, *old) if err != nil { j.client.Log.Error("Failed to update legal hold", err) - continue + return } - lh = *newLH - j.client.Log.Info("legal hold executed", "legal_hold_id", lh.ID, "legal_hold_name", lh.Name) + legalHold = newLH + j.client.Log.Info(fmt.Sprintf("%v", legalHold)) } } } + _ = ctx _ = settings } diff --git a/webapp/src/client.ts b/webapp/src/client.ts index 0b0e9b9..b0fed61 100644 --- a/webapp/src/client.ts +++ b/webapp/src/client.ts @@ -27,6 +27,11 @@ class APIClient { return this.doWithBody(url, 'post', {}); }; + runLegalHold = (id: string) => { + const url = `${this.url}/legalhold/${id}/run`; + return this.doWithBody(url, 'post', {}); + }; + updateLegalHold = (id: string, data: UpdateLegalHold) => { const url = `${this.url}/legalholds/${id}`; return this.doWithBody(url, 'put', data); diff --git a/webapp/src/components/confirm_run.tsx b/webapp/src/components/confirm_run.tsx new file mode 100644 index 0000000..3f61e91 --- /dev/null +++ b/webapp/src/components/confirm_run.tsx @@ -0,0 +1,82 @@ +import React, {useState} from 'react'; + +import {LegalHold} from '@/types'; +import {GenericModal} from '@/components/mattermost-webapp/generic_modal/generic_modal'; + +import './create_legal_hold_form.scss'; + +interface ConfirmRunProps { + legalHold: LegalHold | null; + runLegalHold: (id: string) => Promise; + onExited: () => void; + visible: boolean; +} + +const ConfirmRun = (props: ConfirmRunProps) => { + const [saving, setSaving] = useState(false); + const [serverError, setServerError] = useState(''); + + const run = () => { + if (!props.legalHold) { + return; + } + + setSaving(true); + props.runLegalHold(props.legalHold.id).then((_) => { + props.onExited(); + setSaving(false); + }).catch((error) => { + setSaving(false); + setServerError(error.toString()); + }); + }; + + const onCancel = () => { + props.onExited(); + }; + + const runMessage = (lh: LegalHold|null) => { + if (lh) { + return ( + + {'You have requested to run the following Legal Hold: '} + {'"'}{lh.display_name}{'"'} + {'. This will schedule the legal hold to run as soon as possible, updating it to the current point ' + + 'in time. In a few minutes you will be able to download the new legal hold data.'} + + ); + } + + return ( + + ); + }; + + const message = runMessage(props.legalHold); + + return ( + +
+ {message} +
+
+ ); +}; + +export default ConfirmRun; + diff --git a/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx b/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx index 3a63183..2d248eb 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx +++ b/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx @@ -17,6 +17,7 @@ interface LegalHoldRowProps { users: UserProfile[]; releaseLegalHold: Function; showUpdateModal: Function; + runLegalHold: Function; showSecretModal: Function; } @@ -29,6 +30,10 @@ const LegalHoldRow = (props: LegalHoldRowProps) => { props.releaseLegalHold(lh); }; + const run = () => { + props.runLegalHold(lh); + }; + const downloadUrl = Client.downloadUrl(lh.id); return ( @@ -132,6 +137,11 @@ const LegalHoldRow = (props: LegalHoldRowProps) => { onClick={release} className={'btn btn-danger'} >{'Release'} + {'Run Now'}
); diff --git a/webapp/src/components/legal_hold_table/legal_hold_table.tsx b/webapp/src/components/legal_hold_table/legal_hold_table.tsx index 00fa72e..576b0e8 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_table.tsx +++ b/webapp/src/components/legal_hold_table/legal_hold_table.tsx @@ -10,6 +10,7 @@ interface LegalHoldTableProps { }, releaseLegalHold: Function, showUpdateModal: Function, + runLegalHold: Function, showSecretModal: Function, } @@ -51,6 +52,7 @@ const LegalHoldTable = (props: LegalHoldTableProps) => { key={'legalhold_' + legalHold.id} releaseLegalHold={props.releaseLegalHold} showUpdateModal={props.showUpdateModal} + runLegalHold={props.runLegalHold} showSecretModal={props.showSecretModal} /> ); diff --git a/webapp/src/components/legal_holds_setting.tsx b/webapp/src/components/legal_holds_setting.tsx index 03bd87e..2f5fe13 100644 --- a/webapp/src/components/legal_holds_setting.tsx +++ b/webapp/src/components/legal_holds_setting.tsx @@ -8,11 +8,12 @@ import {CreateLegalHold, LegalHold, UpdateLegalHold} from '@/types'; import CreateLegalHoldButton from '@/components/create_legal_hold_button'; import CreateLegalHoldForm from '@/components/create_legal_hold_form'; import LegalHoldTable from '@/components/legal_hold_table'; -import UpdateLegalHoldForm from '@/components/update_legal_hold_form'; import ShowSecretModal from '@/components/show_secret_modal'; import ConfirmRelease from '@/components/confirm_release'; import LegalHoldIcon from '@/components/legal_hold_icon.svg'; +import UpdateLegalHoldForm from '@/components/update_legal_hold_form'; +import ConfirmRun from '@/components/confirm_run'; const LegalHoldsSetting = () => { const [legalHoldsFetched, setLegalHoldsFetched] = useState(false); @@ -21,6 +22,7 @@ const LegalHoldsSetting = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false); const [showReleaseModal, setShowReleaseModal] = useState(false); + const [showRunModal, setShowRunModal] = useState(false); const [showSecretModal, setShowSecretModal] = useState(false); const [activeLegalHold, setActiveLegalHold] = useState(null); @@ -59,6 +61,17 @@ const LegalHoldsSetting = () => { } }; + const runLegalHold = async (id: string) => { + try { + const response = await Client.runLegalHold(id); + setLegalHoldsFetched(false); + return response; + } catch (error) { + console.log(error); //eslint-disable-line no-console + throw error; + } + }; + const doShowUpdateModal = (legalHold: LegalHold) => { setActiveLegalHold(legalHold); setShowUpdateModal(true); @@ -69,6 +82,11 @@ const LegalHoldsSetting = () => { setShowReleaseModal(true); }; + const doShowRunModal = (legalHold: LegalHold) => { + setActiveLegalHold(legalHold); + setShowRunModal(true); + }; + const doShowSecretModal = (legalHold: LegalHold) => { setActiveLegalHold(legalHold); setShowSecretModal(true); @@ -170,6 +188,7 @@ const LegalHoldsSetting = () => { legalHolds={legalHolds} releaseLegalHold={doShowReleaseModal} showUpdateModal={doShowUpdateModal} + runLegalHold={doShowRunModal} showSecretModal={doShowSecretModal} /> )} @@ -208,6 +227,13 @@ const LegalHoldsSetting = () => { visible={showReleaseModal} /> + setShowRunModal(false)} + visible={showRunModal} + /> + );