fleet/server/mdm/android/tests/integration_software_test.go
Jahziel Villasana-Espinoza 0a3c6c35d3
Android software ingestion (#33826)
> 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>
2025-10-08 10:24:38 -04:00

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,
}
}