diff --git a/applications/datastore.go b/applications/datastore.go index b811d412..92528304 100644 --- a/applications/datastore.go +++ b/applications/datastore.go @@ -15,10 +15,16 @@ type Datastore interface { New(a *Application) (string, error) Applications(params ...interface{}) ([]Application, error) GetApplicationsByDeviceUUID(deviceUUID string) (*[]Application, error) + SaveApplicationByDeviceUUID(deviceUUID string, app *Application) error } type pgStore struct { *sqlx.DB + logger kitlog.Logger +} + +func NewDatastore(connection *sqlx.DB, logger kitlog.Logger) (Datastore, error) { + return pgStore{DB: connection, logger: logger}, nil } func NewDB(driver, conn string, logger kitlog.Logger) (Datastore, error) { @@ -26,7 +32,7 @@ func NewDB(driver, conn string, logger kitlog.Logger) (Datastore, error) { case "postgres": db, err := sqlx.Open(driver, conn) if err != nil { - return nil, errors.Wrap(err, "device datastore") + return nil, errors.Wrap(err, "applications datastore") } var dbError error maxAttempts := 20 @@ -39,7 +45,7 @@ func NewDB(driver, conn string, logger kitlog.Logger) (Datastore, error) { time.Sleep(time.Duration(attempts) * time.Second) } if dbError != nil { - return nil, errors.Wrap(dbError, "device datastore") + return nil, errors.Wrap(dbError, "applications datastore") } return pgStore{DB: db}, nil default: @@ -83,13 +89,13 @@ func (store pgStore) New(a *Application) (string, error) { dynamic_size, is_validated ) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($0, $1, $2, $3, $4, $5, $6) ON CONFLICT (name, version) DO UPDATE SET - identifier=$2, - short_version=$3, - bundle_size=$5, - dynamic_size=$6, - is_validated=$7 + identifier=$1, + short_version=$2, + bundle_size=$4, + dynamic_size=$5, + is_validated=$6 RETURNING application_uuid;`, a.Name, a.Identifier, @@ -135,6 +141,16 @@ func (store pgStore) GetApplicationsByDeviceUUID(deviceUUID string) (*[]Applicat return &apps, nil } +// Associate the given applications with the given device uuid by inserting into `device_applications`. +func (store pgStore) SaveApplicationByDeviceUUID(deviceUUID string, app *Application) error { + stmt := `INSERT INTO devices_applications ( + device_uuid, application_uuid + ) VALUES ($1, $2)` + + _, err := store.Exec(stmt, deviceUUID, app.UUID) + return err +} + // whereer is for building args passed into a method which finds resources type whereer interface { where() string diff --git a/applications/datastore_test.go b/applications/datastore_test.go index b0580a35..0aaa8ceb 100644 --- a/applications/datastore_test.go +++ b/applications/datastore_test.go @@ -1 +1,241 @@ package applications + +import ( + "database/sql" + "github.com/go-kit/kit/log" + "github.com/jmoiron/sqlx" + "gopkg.in/DATA-DOG/go-sqlmock.v1" + "testing" +) + +const MockUUID string = "ABCD-EFGH-IJKL" +const MockName string = "Mock Application" + +//func TestNewDB(t *testing.T) { +// var log log.Logger = log.NewNopLogger() +// appsDB, err := NewDB("postgres", "host=localhost", log) +// +// if err != nil { +// t.Error(err) +// } +// +// if _, ok := appsDB.(Datastore); !ok { +// t.Log("Did not get a datastore") +// t.Fail() +// } +//} + +func TestNewDatastore(t *testing.T) { + var log log.Logger = log.NewNopLogger() + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + dbx := sqlx.NewDb(db, "mock") + defer dbx.Close() + + if _, err := NewDatastore(dbx, log); err != nil { + t.Error(err) + } +} + +func TestNewApplication(t *testing.T) { + var log log.Logger = log.NewNopLogger() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + dbx := sqlx.NewDb(db, "mock") + defer dbx.Close() + + appsDs, err := NewDatastore(dbx, log) + if err != nil { + t.Error(err) + } + + // macOS style: no DynamicSize, no IsValidated + fixture := Application{ + Name: "Keychain Access", + Identifier: sql.NullString{"com.apple.keychainaccess", true}, + ShortVersion: sql.NullString{"9.0", true}, + Version: sql.NullString{"9.0", true}, + BundleSize: sql.NullInt64{14166172, true}, + } + + newRow := sqlmock.NewRows([]string{"application_uuid"}).AddRow(MockUUID) + mock.ExpectQuery("INSERT INTO applications").WithArgs( + fixture.Name, + fixture.Identifier.String, + fixture.ShortVersion.String, + fixture.Version.String, + fixture.BundleSize.Int64, + nil, + nil, + ).WillReturnRows(newRow) + + appUuid, err := appsDs.New(&fixture) + if err != nil { + t.Error(err) + } + + if appUuid != MockUUID { + t.Errorf("inserting a mock application did not return the mock uuid, got: %s", appUuid) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} + +func TestApplications(t *testing.T) { + var log log.Logger = log.NewNopLogger() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + dbx := sqlx.NewDb(db, "mock") + defer dbx.Close() + + appsDs, err := NewDatastore(dbx, log) + if err != nil { + t.Error(err) + } + + mock.ExpectQuery(`SELECT .* FROM applications`).WillReturnRows( + sqlmock.NewRows([]string{"application_uuid"}), + ) + + if _, err := appsDs.Applications(); err != nil { + t.Error(err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} + +func TestApplicationsWhereUUID(t *testing.T) { + var log log.Logger = log.NewNopLogger() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + dbx := sqlx.NewDb(db, "mock") + defer dbx.Close() + + appsDs, err := NewDatastore(dbx, log) + if err != nil { + t.Error(err) + } + + mockRow := sqlmock.NewRows([]string{"application_uuid"}).AddRow(MockUUID) + mock.ExpectQuery(`WHERE application_uuid =`).WithArgs(MockUUID).WillReturnRows(mockRow) + + apps, err := appsDs.Applications(UUID{MockUUID}) + if err != nil { + t.Error(err) + } + + if len(apps) != 1 { + t.Fatalf("unexpected number of results returned: %d", len(apps)) + } + + if apps[0].UUID != MockUUID { + t.Errorf("unexpected application uuid when querying: %s", apps[0].UUID) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} + +func TestApplicationsWhereName(t *testing.T) { + var log log.Logger = log.NewNopLogger() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + dbx := sqlx.NewDb(db, "mock") + defer dbx.Close() + + appsDs, err := NewDatastore(dbx, log) + if err != nil { + t.Error(err) + } + + mockRow := sqlmock.NewRows([]string{"application_uuid", "name"}).AddRow(MockUUID, MockName) + mock.ExpectQuery(`WHERE name =`).WithArgs(MockName).WillReturnRows(mockRow) + + apps, err := appsDs.Applications(Name{MockName}) + if err != nil { + t.Error(err) + } + + if len(apps) != 1 { + t.Fatalf("unexpected number of results returned: %d", len(apps)) + } + + if apps[0].UUID != MockUUID { + t.Errorf("unexpected application uuid when querying: %s", apps[0].UUID) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} + +func TestGetApplicationsByDeviceUUID(t *testing.T) { + var log log.Logger = log.NewNopLogger() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + dbx := sqlx.NewDb(db, "mock") + defer dbx.Close() + + appsDs, err := NewDatastore(dbx, log) + if err != nil { + t.Error(err) + } + + mockRow := sqlmock.NewRows([]string{"application_uuid", "name"}).AddRow(MockUUID, MockName) + mock.ExpectQuery(`WHERE devices_applications.device_uuid=`).WithArgs(MockUUID).WillReturnRows(mockRow) + + if _, err := appsDs.GetApplicationsByDeviceUUID(MockUUID); err != nil { + t.Error(err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} + +func TestSaveApplicationByDeviceUUID(t *testing.T) { + var log log.Logger = log.NewNopLogger() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + dbx := sqlx.NewDb(db, "mock") + defer dbx.Close() + + appsDs, err := NewDatastore(dbx, log) + if err != nil { + t.Error(err) + } + + fixture := Application{ + Name: "Keychain Access", + Identifier: sql.NullString{"com.apple.keychainaccess", true}, + ShortVersion: sql.NullString{"9.0", true}, + Version: sql.NullString{"9.0", true}, + BundleSize: sql.NullInt64{14166172, true}, + } + if err := appsDs.SaveApplicationByDeviceUUID(MockUUID, &fixture); err != nil { + t.Error(err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} diff --git a/connect/service.go b/connect/service.go index 8a232bec..62c7c68b 100644 --- a/connect/service.go +++ b/connect/service.go @@ -2,7 +2,9 @@ package connect import ( "encoding/json" + "fmt" "github.com/micromdm/mdm" + "github.com/micromdm/micromdm/applications" "github.com/micromdm/micromdm/command" "github.com/micromdm/micromdm/device" "github.com/pkg/errors" @@ -18,15 +20,17 @@ type Service interface { } // NewService creates a mdm service -func NewService(devices device.Datastore, cs command.Service) Service { +func NewService(devices device.Datastore, apps applications.Datastore, cs command.Service) Service { return &service{ commands: cs, devices: devices, + apps: apps, } } type service struct { devices device.Datastore + apps applications.Datastore commands command.Service } @@ -126,6 +130,60 @@ func (svc service) ackQueryResponses(req mdm.Response) error { return svc.devices.Save("queryResponses", &existing) } -//func (svc service) ackInstalledApplicationList(req mdm.Response) error { -// -//} +// Acknowledge a response to `InstalledApplicationList`. +func (svc service) ackInstalledApplicationList(req mdm.Response) error { + device, err := svc.devices.GetDeviceByUDID(req.UDID) + if err != nil { + return err + } + + deviceApps, err := svc.apps.GetApplicationsByDeviceUUID(device.UUID) + if err != nil { + return err + } + + // Any installed applications that are already represented in the applications datastore should be skipped. + var updated []applications.Application = make([]applications.Application, len(req.InstalledApplicationList)) + +skip: + for _, ackApp := range req.InstalledApplicationList { + for _, app := range *deviceApps { + if app.Name == ackApp.Name && app.Version == ackApp.Version { + continue skip + } + } + + updated = append(updated, ackApp) + } + + if len(updated) == 0 { + return nil + } + + // Determine applications which we have no record of at all, then insert them (find or create). + for _, newApp := range updated { + existing, err := svc.apps.Applications(applications.Name{newApp.Name}, applications.Version{newApp.Version}) + if err != nil { + return err + } + + switch { + case len(existing) > 1: + return fmt.Errorf("expected a single application match for application name: %s, got %d results", newApp.Name, len(existing)) + case len(existing) == 0: // No record exists and therefore both the application row and device association must be created. + appUuid, err := svc.apps.New(newApp) + if err != nil { + return err + } + + newApp.UUID = appUuid + } + + // For both len(existing) == 0 and len(existing) == 1, the row must be inserted for devices_applications. + if err := svc.apps.SaveApplicationByDeviceUUID(device.UUID, newApp.UUID); err != nil { + return err + } + } + + return nil +} diff --git a/main.go b/main.go index b16118f3..fbb9a544 100644 --- a/main.go +++ b/main.go @@ -204,12 +204,15 @@ func main() { commandSvc := command.NewService(commandDB) checkinSvc := checkin.NewService(deviceDB, mgmtSvc, commandSvc, enrollmentProfile) connectSvc := connect.NewService(deviceDB, commandSvc) + connectSvc := connect.NewService(deviceDB, appsDB, commandSvc) + enrollSvc, _ := enroll.NewService(*flPushCert, *flPushPass, *flTLSCACert, *flSCEPURL, *flURL) httpLogger := log.NewContext(logger).With("component", "http") managementHandler := management.ServiceHandler(ctx, mgmtSvc, httpLogger) commandHandler := command.ServiceHandler(ctx, commandSvc, httpLogger) checkinHandler := checkin.ServiceHandler(ctx, checkinSvc, httpLogger) connectHandler := connect.ServiceHandler(ctx, connectSvc, httpLogger) + enrollHandler := enroll.ServiceHandler(ctx, enrollSvc, httpLogger) mux := http.NewServeMux()