mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
> Closes #33581 <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves # # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Database migrations - [x] Checked table schema to confirm autoupdate - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). --------- Co-authored-by: RachelElysia <rachel@fleetdm.com>
383 lines
14 KiB
Go
383 lines
14 KiB
Go
package tests
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/go-json-experiment/json"
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
"google.golang.org/api/androidmanagement/v1"
|
|
)
|
|
|
|
func TestServiceSoftware(t *testing.T) {
|
|
testingSuite := new(softwareTestSuite)
|
|
suite.Run(t, testingSuite)
|
|
}
|
|
|
|
type softwareTestSuite struct {
|
|
WithServer
|
|
}
|
|
|
|
func (s *softwareTestSuite) SetupSuite() {
|
|
s.WithServer.SetupSuite(s.T(), "androidSoftwareTestSuite")
|
|
s.Token = "testtoken"
|
|
}
|
|
|
|
func (s *softwareTestSuite) TestAndroidSoftwareIngestion() {
|
|
ctx := context.Background()
|
|
t := s.T()
|
|
|
|
// Create enterprise
|
|
var signupResp android.EnterpriseSignupResponse
|
|
s.DoJSON("GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupResp)
|
|
assert.Equal(s.T(), EnterpriseSignupURL, signupResp.Url)
|
|
s.T().Logf("callbackURL: %s", s.ProxyCallbackURL)
|
|
|
|
s.FleetSvc.On("NewActivity", mock.Anything, mock.Anything, mock.AnythingOfType("fleet.ActivityTypeEnabledAndroidMDM")).Return(nil)
|
|
const enterpriseToken = "enterpriseToken"
|
|
s.Do("GET", s.ProxyCallbackURL, nil, http.StatusOK, "enterpriseToken", enterpriseToken)
|
|
|
|
// Update the LIST mock to return the enterprise after "creation"
|
|
s.AndroidAPIClient.EnterprisesListFunc = func(_ context.Context, _ string) ([]*androidmanagement.Enterprise, error) {
|
|
return []*androidmanagement.Enterprise{
|
|
{Name: "enterprises/" + EnterpriseID},
|
|
}, nil
|
|
}
|
|
|
|
resp := android.GetEnterpriseResponse{}
|
|
s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusOK, &resp)
|
|
assert.Equal(t, EnterpriseID, resp.EnterpriseID)
|
|
|
|
// Need to set this because app config is mocked in this setup
|
|
s.AppConfig.MDM.AndroidEnabledAndConfigured = true
|
|
|
|
err := s.DS.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: "enrollsecret"}})
|
|
require.NoError(t, err)
|
|
|
|
secrets, err := s.DS.Datastore.GetEnrollSecrets(ctx, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, secrets, 1)
|
|
|
|
assets, err := s.DS.Datastore.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken}, nil)
|
|
require.NoError(t, err)
|
|
pubsubToken := assets[fleet.MDMAssetAndroidPubSubToken]
|
|
require.NotEmpty(t, pubsubToken.Value)
|
|
|
|
deviceID1 := createAndroidDeviceID("test-android")
|
|
deviceID2 := createAndroidDeviceID("test-android-2")
|
|
|
|
enterpriseSpecificID1 := strings.ToUpper(uuid.New().String())
|
|
enterpriseSpecificID2 := strings.ToUpper(uuid.New().String())
|
|
var req service.PubSubPushRequest
|
|
for _, d := range []struct {
|
|
id string
|
|
esi string
|
|
}{{deviceID1, enterpriseSpecificID1}, {deviceID2, enterpriseSpecificID2}} {
|
|
enrollmentMessage := enrollmentMessageWithEnterpriseSpecificID(
|
|
t,
|
|
androidmanagement.Device{
|
|
Name: d.id,
|
|
EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, secrets[0].Secret),
|
|
},
|
|
d.esi,
|
|
)
|
|
|
|
req = service.PubSubPushRequest{
|
|
PubSubMessage: *enrollmentMessage,
|
|
}
|
|
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
}
|
|
|
|
// Send device data including software for device 1
|
|
software1 := []*androidmanagement.ApplicationReport{{
|
|
DisplayName: "Google Chrome",
|
|
PackageName: "com.google.chrome",
|
|
VersionName: "1.0.0",
|
|
State: "INSTALLED",
|
|
}}
|
|
deviceData1 := createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
|
|
req = service.PubSubPushRequest{
|
|
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
|
|
}
|
|
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
|
|
// Send device data including software for device 2
|
|
software2 := []*androidmanagement.ApplicationReport{{
|
|
DisplayName: "Google Chrome",
|
|
PackageName: "com.google.chrome",
|
|
VersionName: "2.0.0",
|
|
State: "INSTALLED",
|
|
}}
|
|
deviceData2 := createAndroidDeviceWithSoftware(enterpriseSpecificID2, "test-android-2", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software2)
|
|
req = service.PubSubPushRequest{
|
|
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData2),
|
|
}
|
|
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
|
|
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
|
|
// Check software table for correct values, we should have as many rows as there were ApplicationReports sent
|
|
var software []*fleet.Software
|
|
err := sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software")
|
|
require.NoError(t, err)
|
|
assert.Len(t, software, len(deviceData1.ApplicationReports)+len(deviceData2.ApplicationReports))
|
|
|
|
// Check software_titles, we should have fewer rows here, because some ApplicationRows map to the same title.
|
|
var titles []fleet.SoftwareTitle
|
|
err = sqlx.SelectContext(ctx, q, &titles, "SELECT id, name, application_id, source FROM software_titles")
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, titles, 1)
|
|
|
|
for _, got := range software {
|
|
// Validate that both softwares map to the same title
|
|
assert.Equal(t, *got.TitleID, titles[0].ID)
|
|
|
|
// Check other fields are as expected
|
|
assert.Equal(t, "com.google.chrome", *got.ApplicationID)
|
|
assert.Equal(t, "Google Chrome", got.Name)
|
|
assert.Equal(t, "android_apps", got.Source)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Add some new software for the first device
|
|
software1 = []*androidmanagement.ApplicationReport{
|
|
{
|
|
DisplayName: "Google Chrome",
|
|
PackageName: "com.google.chrome",
|
|
VersionName: "1.0.0",
|
|
State: "INSTALLED",
|
|
},
|
|
{
|
|
DisplayName: "YouTube",
|
|
PackageName: "com.google.youtube",
|
|
VersionName: "1.0.0",
|
|
State: "INSTALLED",
|
|
},
|
|
{
|
|
DisplayName: "Google Drive",
|
|
PackageName: "com.google.drive",
|
|
VersionName: "1.0.0",
|
|
State: "INSTALLED",
|
|
},
|
|
}
|
|
deviceData1 = createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
|
|
req = service.PubSubPushRequest{
|
|
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
|
|
}
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
|
|
var hostID1 uint
|
|
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
|
|
// Get host id
|
|
err := sqlx.GetContext(ctx, q, &hostID1, "SELECT id FROM hosts WHERE uuid = ?", enterpriseSpecificID1)
|
|
require.NoError(t, err)
|
|
|
|
var software []*fleet.Software
|
|
err = sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software JOIN host_software ON host_software.software_id = software.id WHERE host_software.host_id = ?", hostID1)
|
|
require.NoError(t, err)
|
|
assert.Len(t, software, len(deviceData1.ApplicationReports)) // chrome, youtube, google drive
|
|
|
|
expectedSW := map[string]fleet.Software{
|
|
"com.google.chrome": {ApplicationID: ptr.String("com.google.chrome"), Name: "Google Chrome", Source: "android_apps"},
|
|
"com.google.youtube": {ApplicationID: ptr.String("com.google.youtube"), Name: "YouTube", Source: "android_apps"},
|
|
"com.google.drive": {ApplicationID: ptr.String("com.google.drive"), Name: "Google Drive", Source: "android_apps"},
|
|
}
|
|
|
|
for _, got := range software {
|
|
// Check other fields are as expected
|
|
require.NotNil(t, got.ApplicationID)
|
|
expected, ok := expectedSW[*got.ApplicationID]
|
|
require.Truef(t, ok, "got unexpected software with application id %s", got.ApplicationID)
|
|
|
|
assert.Equal(t, *expected.ApplicationID, *got.ApplicationID)
|
|
assert.Equal(t, expected.Name, got.Name)
|
|
assert.Equal(t, expected.Source, got.Source)
|
|
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
// Remove some software from the first device
|
|
software1 = []*androidmanagement.ApplicationReport{
|
|
{
|
|
DisplayName: "YouTube",
|
|
PackageName: "com.google.youtube",
|
|
VersionName: "1.0.0",
|
|
State: "INSTALLED",
|
|
},
|
|
}
|
|
deviceData1 = createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
|
|
req = service.PubSubPushRequest{
|
|
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
|
|
}
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
|
|
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
|
|
var software []*fleet.Software
|
|
err = sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software JOIN host_software ON host_software.software_id = software.id WHERE host_software.host_id = ?", hostID1)
|
|
require.NoError(t, err)
|
|
assert.Len(t, software, len(deviceData1.ApplicationReports)) // just youtube now
|
|
|
|
expectedSW := map[string]fleet.Software{
|
|
"com.google.youtube": {ApplicationID: ptr.String("com.google.youtube"), Name: "YouTube", Source: "android_apps"},
|
|
}
|
|
|
|
for _, got := range software {
|
|
// Check other fields are as expected
|
|
require.NotNil(t, got.ApplicationID)
|
|
expected, ok := expectedSW[*got.ApplicationID]
|
|
require.Truef(t, ok, "got unexpected software with application id %s", got.ApplicationID)
|
|
|
|
assert.Equal(t, *expected.ApplicationID, *got.ApplicationID)
|
|
assert.Equal(t, expected.Name, got.Name)
|
|
assert.Equal(t, expected.Source, got.Source)
|
|
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
// Send the same software again, nothing changes
|
|
deviceData1 = createAndroidDeviceWithSoftware(enterpriseSpecificID1, "test-android", createAndroidDeviceID("test-policy"), ptr.Int(1), nil, software1)
|
|
req = service.PubSubPushRequest{
|
|
PubSubMessage: createStatusReportMessageFromDevice(t, deviceData1),
|
|
}
|
|
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value))
|
|
|
|
mysql.ExecAdhocSQL(t, s.DS.Datastore, func(q sqlx.ExtContext) error {
|
|
var software []*fleet.Software
|
|
err = sqlx.SelectContext(ctx, q, &software, "SELECT id, name, application_id, source, title_id FROM software JOIN host_software ON host_software.software_id = software.id WHERE host_software.host_id = ?", hostID1)
|
|
require.NoError(t, err)
|
|
assert.Len(t, software, len(deviceData1.ApplicationReports)) // just youtube now
|
|
|
|
expectedSW := map[string]fleet.Software{
|
|
"com.google.youtube": {ApplicationID: ptr.String("com.google.youtube"), Name: "YouTube", Source: "android_apps"},
|
|
}
|
|
|
|
for _, got := range software {
|
|
// Check other fields are as expected
|
|
require.NotNil(t, got.ApplicationID)
|
|
expected, ok := expectedSW[*got.ApplicationID]
|
|
require.Truef(t, ok, "got unexpected software with application id %s", got.ApplicationID)
|
|
|
|
assert.Equal(t, expected.ApplicationID, got.ApplicationID)
|
|
assert.Equal(t, expected.Name, got.Name)
|
|
assert.Equal(t, expected.Source, got.Source)
|
|
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
}
|
|
|
|
func enrollmentMessageWithEnterpriseSpecificID(t *testing.T, deviceInfo androidmanagement.Device, enterpriseSpecificID string) *android.PubSubMessage {
|
|
deviceInfo.HardwareInfo = &androidmanagement.HardwareInfo{
|
|
EnterpriseSpecificId: enterpriseSpecificID,
|
|
Brand: "TestBrand",
|
|
Model: "TestModel",
|
|
SerialNumber: "test-serial",
|
|
Hardware: "test-hardware",
|
|
}
|
|
deviceInfo.SoftwareInfo = &androidmanagement.SoftwareInfo{
|
|
AndroidBuildNumber: "test-build",
|
|
AndroidVersion: "1",
|
|
}
|
|
deviceInfo.MemoryInfo = &androidmanagement.MemoryInfo{
|
|
TotalRam: int64(8 * 1024 * 1024 * 1024), // 8GB RAM in bytes
|
|
TotalInternalStorage: int64(64 * 1024 * 1024 * 1024), // 64GB system partition
|
|
}
|
|
|
|
deviceInfo.MemoryEvents = []*androidmanagement.MemoryEvent{
|
|
{
|
|
EventType: "EXTERNAL_STORAGE_DETECTED",
|
|
ByteCount: int64(64 * 1024 * 1024 * 1024), // 64GB external/built-in storage total capacity
|
|
CreateTime: "2024-01-15T09:00:00Z",
|
|
},
|
|
{
|
|
EventType: "INTERNAL_STORAGE_MEASURED",
|
|
ByteCount: int64(10 * 1024 * 1024 * 1024), // 10GB free in system partition
|
|
CreateTime: "2024-01-15T10:00:00Z",
|
|
},
|
|
{
|
|
EventType: "EXTERNAL_STORAGE_MEASURED",
|
|
ByteCount: int64(25 * 1024 * 1024 * 1024), // 25GB free in external/built-in storage
|
|
CreateTime: "2024-01-15T10:00:00Z",
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(deviceInfo)
|
|
require.NoError(t, err)
|
|
|
|
encodedData := base64.StdEncoding.EncodeToString(data)
|
|
|
|
return &android.PubSubMessage{
|
|
Attributes: map[string]string{
|
|
"notificationType": string(android.PubSubEnrollment),
|
|
},
|
|
Data: encodedData,
|
|
}
|
|
}
|
|
|
|
func createAndroidDeviceID(name string) string {
|
|
return "enterprises/mock-enterprise-id/devices/" + name
|
|
}
|
|
|
|
func createAndroidDeviceWithSoftware(deviceId, name, policyName string, policyVersion *int, nonComplianceDetails []*androidmanagement.NonComplianceDetail, software []*androidmanagement.ApplicationReport) androidmanagement.Device {
|
|
return androidmanagement.Device{
|
|
Name: createAndroidDeviceID(name),
|
|
NonComplianceDetails: nonComplianceDetails,
|
|
HardwareInfo: &androidmanagement.HardwareInfo{
|
|
EnterpriseSpecificId: deviceId,
|
|
Brand: "TestBrand",
|
|
Model: "TestModel",
|
|
SerialNumber: "test-serial",
|
|
Hardware: "test-hardware",
|
|
},
|
|
SoftwareInfo: &androidmanagement.SoftwareInfo{
|
|
AndroidBuildNumber: "test-build",
|
|
AndroidVersion: "1",
|
|
},
|
|
MemoryInfo: &androidmanagement.MemoryInfo{
|
|
TotalRam: int64(8 * 1024 * 1024 * 1024), // 8GB RAM in bytes
|
|
TotalInternalStorage: int64(64 * 1024 * 1024 * 1024), // 64GB system partition
|
|
},
|
|
AppliedPolicyName: policyName,
|
|
AppliedPolicyVersion: int64(*policyVersion),
|
|
LastPolicySyncTime: "2001-01-01T00:00:00Z",
|
|
ApplicationReports: software,
|
|
}
|
|
}
|
|
|
|
func createStatusReportMessageFromDevice(t *testing.T, device androidmanagement.Device) android.PubSubMessage {
|
|
data, err := json.Marshal(device)
|
|
require.NoError(t, err)
|
|
|
|
encodedData := base64.StdEncoding.EncodeToString(data)
|
|
|
|
return android.PubSubMessage{
|
|
Attributes: map[string]string{
|
|
"notificationType": string(android.PubSubStatusReport),
|
|
},
|
|
Data: encodedData,
|
|
}
|
|
}
|