From c39a3fec10851177c26caf5dadd4e9c5e29ed3a8 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Mon, 9 Sep 2024 14:43:52 -0500 Subject: [PATCH] Final API changes and test updates. --- ee/server/service/software_installers_test.go | 152 ++++++++++++++++++ server/datastore/mysql/scripts_test.go | 21 +++ server/datastore/mysql/software_installers.go | 6 +- .../mysql/software_installers_test.go | 12 +- server/fleet/software_installer.go | 12 +- server/service/handler.go | 3 +- server/service/integration_enterprise_test.go | 30 +++- 7 files changed, 219 insertions(+), 17 deletions(-) diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 6cdeda7b6f..0abe09b0b3 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -1,13 +1,20 @@ package service import ( + "context" "testing" + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPreProcessUninstallScript(t *testing.T) { + t.Parallel() var input = ` blah$PACKAGE_IDS pkgids=$PACKAGE_ID @@ -63,3 +70,148 @@ more ( assert.Equal(t, expected, payload.UninstallScript) } + +func TestInstallUninstallAuth(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc := newTestService(t, ds) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ + OrbitNodeKey: ptr.String("orbit_key"), + Platform: "darwin", + TeamID: ptr.Uint(1), + }, nil + } + ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, + withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return &fleet.SoftwareInstaller{ + Name: "installer.pkg", + Platform: "darwin", + TeamID: ptr.Uint(1), + }, nil + } + ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID uint, installerID uint) (*fleet.HostLastInstallData, error) { + return nil, nil + } + ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, selfService bool) (string, + error) { + return "request_id", nil + } + ds.GetAnyScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { + return []byte("script"), nil + } + ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, + error) { + return &fleet.HostScriptResult{ + ExecutionID: "execution_id", + }, nil + } + ds.InsertSoftwareUninstallRequestFunc = func(ctx context.Context, executionID string, hostID uint, softwareInstallerID uint) error { + return nil + } + + testCases := []struct { + name string + user *fleet.User + shouldFail bool + }{ + { + "global admin", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + }, + { + "global maintainer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, + false, + }, + { + "global observer", + &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, + true, + }, + { + "team admin", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, + false, + }, + { + "team maintainer", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, + false, + }, + { + "team observer", + &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, + true, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user}) + checkAuthErr(t, tt.shouldFail, svc.InstallSoftwareTitle(ctx, 1, 10)) + checkAuthErr(t, tt.shouldFail, svc.UninstallSoftwareTitle(ctx, 1, 10)) + }) + } +} + +// TestUninstallSoftwareTitle is mostly tested in enterprise integration test. This test hits a few edge cases. +func TestUninstallSoftwareTitle(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc := newTestService(t, ds) + + host := &fleet.Host{ + OrbitNodeKey: ptr.String("orbit_key"), + Platform: "darwin", + TeamID: ptr.Uint(1), + } + + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return host, nil + } + + // Scripts disabled + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + ServerSettings: fleet.ServerSettings{ + ScriptsDisabled: true, + }, + }, nil + } + require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptScriptsDisabledGloballyErrMsg) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + + // Host scripts disabled + host.ScriptsEnabled = ptr.Bool(false) + require.ErrorContains(t, svc.UninstallSoftwareTitle(context.Background(), 1, 10), fleet.RunScriptsOrbitDisabledErrMsg) + +} + +func checkAuthErr(t *testing.T, shouldFail bool, err error) { + t.Helper() + if shouldFail { + require.Error(t, err) + var forbiddenError *authz.Forbidden + require.ErrorAs(t, err, &forbiddenError) + } else { + require.NoError(t, err) + } +} + +func newTestService(t *testing.T, ds fleet.Datastore) *Service { + t.Helper() + authorizer, err := authz.NewAuthorizer() + require.NoError(t, err) + svc := &Service{ + authz: authorizer, + ds: ds, + } + return svc +} diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index cd226f4911..7666f0ae4f 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -37,6 +37,7 @@ func TestScripts(t *testing.T) { {"TestLockUnlockManually", testLockUnlockManually}, {"TestInsertScriptContents", testInsertScriptContents}, {"TestCleanupUnusedScriptContents", testCleanupUnusedScriptContents}, + {"TestGetAnyScriptContents", testGetAnyScriptContents}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -263,6 +264,8 @@ func testScripts(t *testing.T, ds *Datastore) { // get unknown script contents _, err = ds.GetScriptContents(ctx, 123) require.ErrorAs(t, err, &nfe) + _, err = ds.GetAnyScriptContents(ctx, 123) + require.ErrorAs(t, err, &nfe) // create global scriptGlobal scriptGlobal, err := ds.NewScript(ctx, &fleet.Script{ @@ -284,6 +287,9 @@ func testScripts(t *testing.T, ds *Datastore) { contents, err := ds.GetScriptContents(ctx, scriptGlobal.ID) require.NoError(t, err) require.Equal(t, "echo", string(contents)) + contents, err = ds.GetAnyScriptContents(ctx, scriptGlobal.ID) + require.NoError(t, err) + require.Equal(t, "echo", string(contents)) // create team script but team does not exist _, err = ds.NewScript(ctx, &fleet.Script{ @@ -317,6 +323,9 @@ func testScripts(t *testing.T, ds *Datastore) { contents, err = ds.GetScriptContents(ctx, scriptTeam.ID) require.NoError(t, err) require.Equal(t, "echo 'team'", string(contents)) + contents, err = ds.GetAnyScriptContents(ctx, scriptTeam.ID) + require.NoError(t, err) + require.Equal(t, "echo 'team'", string(contents)) // try to create another team script with the same name _, err = ds.NewScript(ctx, &fleet.Script{ @@ -1247,3 +1256,15 @@ func testCleanupUnusedScriptContents(t *testing.T, ds *Datastore) { require.Len(t, sc, 1) require.Equal(t, md5ChecksumScriptContent(res.ScriptContents), sc[0].Checksum) } + +func testGetAnyScriptContents(t *testing.T, ds *Datastore) { + ctx := context.Background() + contents := `echo foobar;` + res, err := insertScriptContents(ctx, ds.writer(ctx), contents) + require.NoError(t, err) + id, _ := res.LastInsertId() + + result, err := ds.GetAnyScriptContents(ctx, uint(id)) + require.NoError(t, err) + require.Equal(t, contents, string(result)) +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index bf9b02f175..599d782e1d 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -447,8 +447,10 @@ func (ds *Datastore) GetSummaryHostSoftwareInstalls(ctx context.Context, install stmt := fmt.Sprintf(` SELECT - COALESCE(SUM( IF(status = :software_status_pending_install OR status = :software_status_pending_uninstall, 1, 0)), 0) AS pending, - COALESCE(SUM( IF(status = :software_status_failed_install OR status = :software_status_failed_uninstall, 1, 0)), 0) AS failed, + COALESCE(SUM( IF(status = :software_status_pending_install, 1, 0)), 0) AS pending_install, + COALESCE(SUM( IF(status = :software_status_failed_install, 1, 0)), 0) AS failed_install, + COALESCE(SUM( IF(status = :software_status_pending_uninstall, 1, 0)), 0) AS pending_uninstall, + COALESCE(SUM( IF(status = :software_status_failed_uninstall, 1, 0)), 0) AS failed_uninstall, COALESCE(SUM( IF(status = :software_status_installed, 1, 0)), 0) AS installed FROM ( SELECT diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index e2e07d58bc..6a728b0a8c 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -331,6 +331,10 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { return nil }) + // Uninstall request with unknown host + err = ds.InsertSoftwareUninstallRequest(ctx, "uuid"+tag+tc, 99999, si.InstallerID) + assert.ErrorContains(t, err, "Host") + userTeamFilter := fleet.TeamFilter{ User: &fleet.User{GlobalRole: ptr.String("admin")}, } @@ -432,9 +436,11 @@ func testSoftwareInstallRequests(t *testing.T, ds *Datastore) { summary, err := ds.GetSummaryHostSoftwareInstalls(ctx, installerMeta.InstallerID) require.NoError(t, err) require.Equal(t, fleet.SoftwareInstallerStatusSummary{ - Installed: 1, - Pending: 2, - Failed: 2, + Installed: 1, + PendingInstall: 1, + FailedInstall: 1, + PendingUninstall: 1, + FailedUninstall: 1, }, *summary) }) } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 0ea9057840..3893c63cf2 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -121,10 +121,14 @@ func (s *SoftwareInstaller) AuthzType() string { type SoftwareInstallerStatusSummary struct { // Installed is the number of hosts that have the software package installed. Installed uint `json:"installed" db:"installed"` - // Pending is the number of hosts that have the software package pending installation. - Pending uint `json:"pending" db:"pending"` - // Failed is the number of hosts that have the software package installation failed. - Failed uint `json:"failed" db:"failed"` + // PendingInstall is the number of hosts that have the software package pending installation. + PendingInstall uint `json:"pending_install" db:"pending_install"` + // FailedInstall is the number of hosts that have the software package installation failed. + FailedInstall uint `json:"failed_install" db:"failed_install"` + // PendingUninstall is the number of hosts that have the software package pending installation. + PendingUninstall uint `json:"pending_uninstall" db:"pending_uninstall"` + // FailedInstall is the number of hosts that have the software package installation failed. + FailedUninstall uint `json:"failed_uninstall" db:"failed_uninstall"` } // SoftwareInstallerStatus represents the status of a software installer package on a host. diff --git a/server/service/handler.go b/server/service/handler.go index 8f59f23b4e..840aab8b83 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -378,7 +378,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC getSoftwareInstallerRequest{}) ue.POST("/api/_version_/fleet/software/package", uploadSoftwareInstallerEndpoint, uploadSoftwareInstallerRequest{}) ue.DELETE("/api/_version_/fleet/software/titles/{title_id:[0-9]+}/available_for_install", deleteSoftwareInstallerEndpoint, deleteSoftwareInstallerRequest{}) - ue.GET("/api/_version_/fleet/software/install/results/{install_uuid}", getSoftwareInstallResultsEndpoint, getSoftwareInstallResultsRequest{}) + ue.GET("/api/_version_/fleet/software/install/{install_uuid}/results", getSoftwareInstallResultsEndpoint, + getSoftwareInstallResultsRequest{}) ue.POST("/api/_version_/fleet/software/batch", batchSetSoftwareInstallersEndpoint, batchSetSoftwareInstallersRequest{}) // App store software diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index c4068723cf..bfdd53dabe 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -11030,7 +11030,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { installUUID := getHostSoftwareResp.Software[0].SoftwarePackage.LastInstall.InstallUUID gsirr := getSoftwareInstallResultsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &gsirr) require.NoError(t, gsirr.Err) require.NotNil(t, gsirr.Results) results := gsirr.Results @@ -11099,9 +11099,9 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { require.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name) require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status) require.Equal(t, fleet.SoftwareInstallerStatusSummary{ - Installed: 1, - Pending: 2, - Failed: 1, + Installed: 1, + PendingInstall: 2, + FailedInstall: 1, }, *titleResp.SoftwareTitle.SoftwarePackage.Status) // status is reflected in list hosts responses and counts when filtering by software title and status @@ -11234,6 +11234,22 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", h.ID), nil, http.StatusOK, &listUpcomingAct) require.Len(t, listUpcomingAct.Activities, 1) assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), listUpcomingAct.Activities[0].Type) + details := make(map[string]interface{}, 5) + require.NoError(t, json.Unmarshal(*listUpcomingAct.Activities[0].Details, &details)) + assert.EqualValues(t, fleet.SoftwareUninstallPending, details["status"]) + + // Check that status is reflected in software title response + titleResp = getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", + strconv.Itoa(int(*teamID))) + require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "ruby.deb", titleResp.SoftwareTitle.SoftwarePackage.Name) + require.NotNil(t, titleResp.SoftwareTitle.SoftwarePackage.Status) + assert.Equal(t, fleet.SoftwareInstallerStatusSummary{ + PendingInstall: 1, + FailedInstall: 1, + PendingUninstall: 1, + }, *titleResp.SoftwareTitle.SoftwarePackage.Status) // Another install/uninstall cannot be send once an uninstall is pending s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", h.ID, titleID), nil, http.StatusBadRequest, &resp) @@ -11252,7 +11268,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { "order_direction", "desc") require.NotEmpty(t, activitiesResp.Activities) assert.Equal(t, fleet.ActivityTypeUninstalledSoftware{}.ActivityName(), activitiesResp.Activities[0].Type) - details := make(map[string]interface{}, 5) + details = make(map[string]interface{}, 5) require.NoError(t, json.Unmarshal(*activitiesResp.Activities[0].Details, &details)) assert.Equal(t, "uninstalled", details["status"]) @@ -11295,7 +11311,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerHostRequests() { require.NotNil(t, instResult.HostDeletedAt) gsirr = getSoftwareInstallResultsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/results/%s", installUUID), nil, http.StatusOK, &gsirr) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/install/%s/results", installUUID), nil, http.StatusOK, &gsirr) require.NoError(t, gsirr.Err) require.NotNil(t, gsirr.Results) results = gsirr.Results @@ -11455,7 +11471,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { } checkResults := func(want result) { var resp getSoftwareInstallResultsResponse - s.DoJSON("GET", "/api/v1/fleet/software/install/results/"+want.InstallUUID, nil, http.StatusOK, &resp) + s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/install/%s/results", want.InstallUUID), nil, http.StatusOK, &resp) assert.Equal(t, want.HostID, resp.Results.HostID) assert.Equal(t, want.InstallUUID, resp.Results.InstallUUID)