diff --git a/device/datastore.go b/device/datastore.go index 33eb2a02..1d084171 100644 --- a/device/datastore.go +++ b/device/datastore.go @@ -23,9 +23,10 @@ var ( dep_profile_assign_time, dep_profile_push_time, dep_profile_assigned_date, - dep_profile_assigned_by + dep_profile_assigned_by, + dep_device ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (serial_number) DO UPDATE SET model = $2, @@ -37,7 +38,8 @@ var ( dep_profile_assign_time = $8, dep_profile_push_time = $9, dep_profile_assigned_date = $10, - dep_profile_assigned_by = $11 + dep_profile_assigned_by = $11, + dep_device = $12 RETURNING device_uuid;` authenticateMDM = `INSERT INTO devices ( @@ -62,12 +64,21 @@ var ( imei=$7, meid=$8 RETURNING device_uuid;` + + selectDevicesStmt = `SELECT device_uuid, + udid, + serial_number, + dep_profile_status, + model, + workflow_uuid + FROM devices` ) // Datastore manages devices in a database type Datastore interface { New(src string, d *Device) (string, error) GetDeviceByUDID(udid string, fields ...string) (*Device, error) + Devices(params ...interface{}) ([]Device, error) } type pgStore struct { @@ -97,6 +108,7 @@ func (store pgStore) New(src string, d *Device) (string, error) { d.DEPProfilePushTime, d.DEPProfileAssignedDate, d.DEPProfileAssignedBy, + true, ).Scan(&d.UUID) if err != nil { return "", err @@ -123,6 +135,38 @@ func (store pgStore) New(src string, d *Device) (string, error) { } } +func (store pgStore) Devices(params ...interface{}) ([]Device, error) { + stmt := selectDevicesStmt + stmt = addWhereFilters(stmt, params...) + var devices []Device + err := store.Select(&devices, stmt) + if err != nil { + return nil, errors.Wrap(err, "pgStore Devices") + } + return devices, nil +} + +// whereer is for building args passed into a method which finds resources +type whereer interface { + where() string +} + +// add WHERE clause from params +func addWhereFilters(stmt string, params ...interface{}) string { + var where []string + for _, param := range params { + if f, ok := param.(whereer); ok { + where = append(where, f.where()) + } + } + + if len(where) != 0 { + whereFilter := strings.Join(where, ",") + stmt = fmt.Sprintf("%s WHERE %s", stmt, whereFilter) + } + return stmt +} + //NewDB creates a Datastore func NewDB(driver, conn string, logger kitlog.Logger) (Datastore, error) { switch driver { @@ -157,7 +201,7 @@ func migrate(db *sqlx.DB) { CREATE TABLE IF NOT EXISTS devices ( device_uuid uuid PRIMARY KEY DEFAULT uuid_generate_v4(), - udid text, + udid text NOT NULL DEFAULT '', serial_number text, os_version text, model text, @@ -178,6 +222,8 @@ func migrate(db *sqlx.DB) { apple_mdm_topic text, apple_push_magic text, mdm_enrolled boolean, + workflow_uuid text NOT NULL DEFAULT '', + dep_device boolean, awaiting_configuration boolean ); CREATE UNIQUE INDEX serial_idx ON devices (serial_number);` diff --git a/device/datastore_test.go b/device/datastore_test.go index ce99d4d2..e340945e 100644 --- a/device/datastore_test.go +++ b/device/datastore_test.go @@ -18,10 +18,19 @@ func TestNewDB(t *testing.T) { } } -func TestInsertFetch(t *testing.T) { +func TestRetrieveDevices(t *testing.T) { ds := datastore(t) defer teardown() + addTestDevices(t, ds) + _, err := ds.Devices() + if err != nil { + t.Fatal(err) + } +} + +func addTestDevices(t *testing.T, ds Datastore) { + now := time.Now() var devicetests = []struct { in Device }{ @@ -56,7 +65,62 @@ func TestInsertFetch(t *testing.T) { Description: "It's a tablet", Color: "pink", AssetTag: "foo", - DEPProfileAssignTime: time.Now(), + DEPProfileAssignTime: &now, + }, + }, + } + + for _, tt := range devicetests { + uuid, err := ds.New("fetch", &tt.in) + if err != nil { + t.Log("failed at", tt.in.SerialNumber) + t.Fatal(err) + } + if len(uuid) != 36 { + t.Errorf("newdevice fetch: expected uuid got %q", uuid) + } + } +} +func TestInsertFetch(t *testing.T) { + ds := datastore(t) + defer teardown() + + now := time.Now() + var devicetests = []struct { + in Device + }{ + { + Device{ + SerialNumber: "DEADBEEF123A", + Model: "Macbook", + Description: "It's a laptop", + Color: "red", + }, + }, + { + Device{ + SerialNumber: "DEADBEEF123A", + Model: "Macbook", + Description: "It's a laptop", + Color: "red", + }, + }, + { + Device{ + SerialNumber: "DEADBEEF123B", + Model: "Macbook", + Description: "It's a laptop", + Color: "blue", + }, + }, + { + Device{ + SerialNumber: "DEADBEEF123C", + Model: "iPad", + Description: "It's a tablet", + Color: "pink", + AssetTag: "foo", + DEPProfileAssignTime: &now, }, }, } @@ -77,6 +141,7 @@ func TestInsertAuthenticate(t *testing.T) { ds := datastore(t) defer teardown() + var now = time.Now() var devicetests = []struct { in Device }{ @@ -129,7 +194,7 @@ func TestInsertAuthenticate(t *testing.T) { Description: "It's a tablet", Color: "pink", AssetTag: "foo", - DEPProfileAssignTime: time.Now(), + DEPProfileAssignTime: &now, }, }, } @@ -149,6 +214,7 @@ func TestInsertAuthenticate(t *testing.T) { func TestGetDeviceByUDID(t *testing.T) { ds := datastore(t) defer teardown() + var now = time.Now() var devicetests = []struct { in Device }{ @@ -205,7 +271,7 @@ func TestGetDeviceByUDID(t *testing.T) { Description: "It's a tablet", Color: "pink", AssetTag: "foo", - DEPProfileAssignTime: time.Now(), + DEPProfileAssignTime: &now, }, }, } diff --git a/device/device.go b/device/device.go index de590eed..bc17d615 100644 --- a/device/device.go +++ b/device/device.go @@ -12,7 +12,7 @@ import ( type Device struct { // Primary key is UUID UUID string `json:"uuid" db:"device_uuid"` - UDID string `json:"udid"` + UDID string `json:"udid,omitempty"` SerialNumber string `json:"serial_number,omitempty" db:"serial_number,omitempty"` OSVersion string `json:"os_version,omitempty" db:"os_version,omitempty"` BuildVersion string `json:"build_version,omitempty" db:"build_version,omitempty"` @@ -26,7 +26,7 @@ type Device struct { Token string `json:"token,omitempty" db:"apple_mdm_token,omitempty"` UnlockToken string `json:"unlock_token,omitempty" db:"unlock_token,omitempty"` Enrolled bool `json:"enrolled,omitempty" db:"mdm_enrolled,omitempty"` - Workflow string `json:"workflow,omitempty" db:"workflow_uuid"` + Workflow string `json:"workflow,omitempty" db:"workflow_uuid,omitempty"` DEPDevice bool `json:"dep_device,omitempty" db:"dep_device,omitempty"` Description string `json:"description,omitempty" db:"description"` Model string `json:"model,omitempty" db:"model"` @@ -34,10 +34,10 @@ type Device struct { AssetTag string `json:"asset_tag,omitempty" db:"asset_tag"` DEPProfileStatus DEPProfileStatus `json:"dep_profile_status,omitempty" db:"dep_profile_status"` DEPProfileUUID string `json:"dep_profile_uuid,omitempty" db:"dep_profile_uuid"` - DEPProfileAssignTime time.Time `json:"dep_profile_assign_time,omitempty" db:"dep_profile_assign_time"` - DEPProfilePushTime time.Time `json:"dep_profile_push_time,omitempty" db:"dep_profile_push_time"` - DEPProfileAssignedDate time.Time `json:"dep_profile_assigned_date" db:"dep_profile_assigned_date"` - DEPProfileAssignedBy string `json:"dep_profile_assigned_by" db:"dep_profile_assigned_by"` + DEPProfileAssignTime *time.Time `json:"dep_profile_assign_time,omitempty" db:"dep_profile_assign_time"` + DEPProfilePushTime *time.Time `json:"dep_profile_push_time,omitempty" db:"dep_profile_push_time"` + DEPProfileAssignedDate *time.Time `json:"dep_profile_assigned_date,omitempty" db:"dep_profile_assigned_date"` + DEPProfileAssignedBy string `json:"dep_profile_assigned_by,omitempty" db:"dep_profile_assigned_by"` } // DEPProfileStatus is the status of the DEP Profile @@ -53,7 +53,12 @@ const ( ) // Value implements Valuer from database/sql -func (status DEPProfileStatus) Value() (driver.Value, error) { return string(status), nil } +func (status DEPProfileStatus) Value() (driver.Value, error) { + if status == "" { + return "empty", nil + } + return string(status), nil +} // Scan implements Scanner from database/sql func (status *DEPProfileStatus) Scan(value interface{}) error { @@ -62,7 +67,7 @@ func (status *DEPProfileStatus) Scan(value interface{}) error { return nil } if sv, err := driver.String.ConvertValue(value); err == nil { - if v, ok := sv.(string); ok { + if v, ok := sv.([]byte); ok { *status = DEPProfileStatus(v) return nil } @@ -82,9 +87,9 @@ func NewFromDEP(dd dep.Device) *Device { AssetTag: dd.AssetTag, DEPProfileStatus: DEPProfileStatus(dd.ProfileStatus), DEPProfileUUID: dd.ProfileUUID, - DEPProfileAssignTime: dd.ProfileAssignTime, - DEPProfilePushTime: dd.ProfilePushTime, - DEPProfileAssignedDate: dd.DeviceAssignedDate, + DEPProfileAssignTime: &dd.ProfileAssignTime, + DEPProfilePushTime: &dd.ProfilePushTime, + DEPProfileAssignedDate: &dd.DeviceAssignedDate, DEPProfileAssignedBy: dd.DeviceAssignedBy, } } diff --git a/management/endpoint.go b/management/endpoint_profile.go similarity index 97% rename from management/endpoint.go rename to management/endpoint_profile.go index fedb1e6d..125ddb23 100644 --- a/management/endpoint.go +++ b/management/endpoint_profile.go @@ -12,7 +12,7 @@ import ( var ( // ErrEmptyRequest is returned if the request body is empty - errEmptyRequest = errors.New("request must contain a profile identifier") + errEmptyRequest = errors.New("request must contain all required fields") errBadRouting = errors.New("inconsistent mapping between route and handler (programmer error)") ) diff --git a/management/endpoint_workflow.go b/management/endpoint_workflow.go new file mode 100644 index 00000000..daf762ee --- /dev/null +++ b/management/endpoint_workflow.go @@ -0,0 +1,57 @@ +package management + +import ( + "encoding/json" + "net/http" + + "github.com/go-kit/kit/endpoint" + "github.com/micromdm/micromdm/workflow" + "golang.org/x/net/context" +) + +type addWorkflowRequest struct { + *workflow.Workflow +} + +type addWorkflowResponse struct { + *workflow.Workflow + Err error `json:"error,omitempty"` +} + +func (r addWorkflowResponse) status() int { return http.StatusCreated } + +func (r addWorkflowResponse) error() error { return r.Err } + +func makeAddWorkflowEndpoint(svc Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + req := request.(addWorkflowRequest) + wf, err := svc.AddWorkflow(req.Workflow) + return addWorkflowResponse{Err: err, Workflow: wf}, nil + } +} + +type listWorkflowsRequest struct{} + +type listWorkflowsResponse struct { + workflows []workflow.Workflow + Err error `json:"error,omitempty"` +} + +func (r listWorkflowsResponse) error() error { return r.Err } + +func (r listWorkflowsResponse) encodeList(w http.ResponseWriter) error { + jsn, err := json.MarshalIndent(r.workflows, "", " ") + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write(jsn) + return nil +} + +func makeListWorkflowsEndpoint(svc Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + workflows, err := svc.Workflows() + return listWorkflowsResponse{Err: err, workflows: workflows}, nil + } +} diff --git a/management/service.go b/management/service.go index 2cbb9658..53aa498d 100644 --- a/management/service.go +++ b/management/service.go @@ -12,10 +12,15 @@ var ErrNotFound = errors.New("not found") // Service is the interface that provides methods for managing devices type Service interface { + // profiles AddProfile(prf *workflow.Profile) (*workflow.Profile, error) Profiles() ([]workflow.Profile, error) Profile(uuid string) (*workflow.Profile, error) DeleteProfile(uuid string) error + // workflows + AddWorkflow(wf *workflow.Workflow) (*workflow.Workflow, error) + Workflows() ([]workflow.Workflow, error) + // dep FetchDEPDevices() error } @@ -73,6 +78,15 @@ func (svc service) FetchDEPDevices() error { return nil } +// workflows svc +func (svc service) AddWorkflow(wf *workflow.Workflow) (*workflow.Workflow, error) { + return svc.workflows.CreateWorkflow(wf) +} + +func (svc service) Workflows() ([]workflow.Workflow, error) { + return svc.workflows.Workflows() +} + // NewService creates a management service func NewService(ds device.Datastore, ws workflow.Datastore, dc dep.Client) Service { return &service{ diff --git a/management/transport.go b/management/transport.go index f1fcda05..01c29229 100644 --- a/management/transport.go +++ b/management/transport.go @@ -59,13 +59,33 @@ func ServiceHandler(ctx context.Context, svc Service, logger kitlog.Logger) http opts..., ) + addWorkflowHandler := kithttp.NewServer( + ctx, + makeAddWorkflowEndpoint(svc), + decodeAddWorkflowRequest, + encodeResponse, + opts..., + ) + listWorkflowsHandler := kithttp.NewServer( + ctx, + makeListWorkflowsEndpoint(svc), + decodeListWorkflowsRequest, + encodeResponse, + opts..., + ) + r := mux.NewRouter() + // dep r.Handle("/management/v1/devices/fetch", fetchDEPHandler).Methods("POST") + // profiles r.Handle("/management/v1/profiles", addProfileHandler).Methods("POST") r.Handle("/management/v1/profiles", listProfilesHandler).Methods("GET") r.Handle("/management/v1/profiles/{uuid}", showProfileHandler).Methods("GET") r.Handle("/management/v1/profiles/{uuid}", deleteProfileHandler).Methods("DELETE") + // workflows + r.Handle("/management/v1/workflows", addWorkflowHandler).Methods("POST") + r.Handle("/management/v1/workflows", listWorkflowsHandler).Methods("GET") return r } @@ -116,6 +136,23 @@ func decodeDeleteProfileRequest(_ context.Context, r *http.Request) (interface{} return deleteProfileRequest{UUID: uuid}, nil } +// workflow +func decodeAddWorkflowRequest(_ context.Context, r *http.Request) (interface{}, error) { + var request addWorkflowRequest + err := json.NewDecoder(r.Body).Decode(&request) + if err == io.EOF { + return nil, errEmptyRequest + } + if request.Name == "" { + return nil, errEmptyRequest + } + return request, err +} + +func decodeListWorkflowsRequest(_ context.Context, r *http.Request) (interface{}, error) { + return listWorkflowsRequest{}, nil +} + func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error { if e, ok := response.(errorer); ok && e.error() != nil { encodeError(ctx, e.error(), w) diff --git a/management/transport_test.go b/management/transport_test.go index c043ec24..994ded94 100644 --- a/management/transport_test.go +++ b/management/transport_test.go @@ -17,6 +17,110 @@ import ( "golang.org/x/net/context" ) +func TestAddWorkflowWithProfiles(t *testing.T) { + server, svc := newServer(t) + defer teardown() + defer server.Close() + + profileData := []byte(`{ + "payload_identifier": "com.micromdm.example2", + "data" : "fooProfile" + }`) + + pfBody := testAddHTTP("profiles", t, svc, server, profileData, http.StatusCreated) + var pf workflow.Profile + err := json.NewDecoder(pfBody).Decode(&pf) + if err != nil { + t.Fatal(err) + } + + wf := workflow.Workflow{ + Name: "test_workflow", + Profiles: []workflow.Profile{pf}, + } + wfData, err := json.Marshal(wf) + if err != nil { + t.Fatal(err) + } + + wfBody := testAddHTTP("workflows", t, svc, server, wfData, http.StatusCreated) + + var returned workflow.Workflow + err = json.NewDecoder(wfBody).Decode(&returned) + if err != nil { + t.Fatal(err) + } + + if !returned.HasProfile(pf.PayloadIdentifier) { + t.Fatal("returned workflow must have profile") + } +} + +// create some workflows to be used by tests +func addWorkflows(t *testing.T, server *httptest.Server, svc Service) []workflow.Workflow { + profileData := []byte(`{ + "payload_identifier": "com.micromdm.example2", + "data" : "fooProfile" + }`) + + pfBody := testAddHTTP("profiles", t, svc, server, profileData, http.StatusCreated) + var pf workflow.Profile + err := json.NewDecoder(pfBody).Decode(&pf) + if err != nil { + t.Fatal(err) + } + + wf := workflow.Workflow{ + Name: "test_workflow", + Profiles: []workflow.Profile{pf}, + } + wfData, err := json.Marshal(wf) + if err != nil { + t.Fatal(err) + } + + wfBody := testAddHTTP("workflows", t, svc, server, wfData, http.StatusCreated) + + var returned workflow.Workflow + err = json.NewDecoder(wfBody).Decode(&returned) + if err != nil { + t.Fatal(err) + } + return []workflow.Workflow{returned} +} + +func TestAddWorkflow(t *testing.T) { + server, svc := newServer(t) + defer teardown() + defer server.Close() + + workflowData := []byte(`{ + "name": "testWorkflow" + }`) + + var addTests = []struct { + in []byte + expected int + }{ + { + in: nil, + expected: http.StatusBadRequest, + }, + { + in: workflowData, + expected: http.StatusCreated, + }, + { + in: workflowData, + expected: http.StatusConflict, + }, + } + + for _, tt := range addTests { + testAddHTTP("workflows", t, svc, server, tt.in, tt.expected) + } +} + func TestDeleteProfile(t *testing.T) { server, svc := newServer(t) defer teardown() @@ -29,7 +133,7 @@ func TestDeleteProfile(t *testing.T) { "data" : "fooProfile" }`) - testAddHTTP(t, svc, server, profileData, http.StatusCreated) + testAddHTTP("profiles", t, svc, server, profileData, http.StatusCreated) profiles := testListHTTP(t, svc, server, http.StatusOK) for _, p := range profiles { testDeleteHTTP(t, svc, server, p.UUID, http.StatusNoContent) @@ -49,7 +153,7 @@ func TestShowProfile(t *testing.T) { testGetHTTP(t, svc, server, "foo", http.StatusBadRequest) testGetHTTP(t, svc, server, "036d339c-4fe4-4d6e-a051-65fafbec8c93", http.StatusNotFound) - testAddHTTP(t, svc, server, profileData, http.StatusCreated) + testAddHTTP("profiles", t, svc, server, profileData, http.StatusCreated) profiles := testListHTTP(t, svc, server, http.StatusOK) for _, p := range profiles { returned := testGetHTTP(t, svc, server, p.UUID, http.StatusOK) @@ -98,6 +202,14 @@ func testGetHTTP(t *testing.T, svc Service, server *httptest.Server, uuid string return &profile } +func TestListWorkflows(t *testing.T) { + server, svc := newServer(t) + defer teardown() + defer server.Close() + addWorkflows(t, server, svc) + testListWorkflowsHTTP(t, svc, server, http.StatusOK) +} + func TestListProfiles(t *testing.T) { server, svc := newServer(t) defer teardown() @@ -108,7 +220,7 @@ func TestListProfiles(t *testing.T) { "data" : "fooProfile" }`) - testAddHTTP(t, svc, server, profileData, http.StatusCreated) + testAddHTTP("profiles", t, svc, server, profileData, http.StatusCreated) testListHTTP(t, svc, server, http.StatusOK) } @@ -142,7 +254,7 @@ func TestAddProfile(t *testing.T) { } for _, tt := range addTests { - testAddHTTP(t, svc, server, tt.in, tt.expected) + testAddHTTP("profiles", t, svc, server, tt.in, tt.expected) } } @@ -156,6 +268,7 @@ func newServer(t *testing.T) (*httptest.Server, Service) { if err != nil { t.Fatal(err) } + ps, err := workflow.NewDB("postgres", testConn, logger) if err != nil { t.Fatal(err) @@ -167,6 +280,28 @@ func newServer(t *testing.T) (*httptest.Server, Service) { return server, svc } +func testListWorkflowsHTTP(t *testing.T, svc Service, server *httptest.Server, expectedStatus int) []workflow.Workflow { + client := http.DefaultClient + theURL := server.URL + "/management/v1/workflows" + resp, err := client.Get(theURL) + if err != nil { + t.Fatal(err) + } + + if resp.StatusCode != expectedStatus { + io.Copy(os.Stdout, resp.Body) + t.Fatal("expected", expectedStatus, "got", resp.StatusCode) + } + + // test decoding the result into a struct + var workflows []workflow.Workflow + if err := json.NewDecoder(resp.Body).Decode(&workflows); err != nil { + t.Log("failed to decode profiles from list response") + t.Fatal(err) + } + return workflows +} + func testListHTTP(t *testing.T, svc Service, server *httptest.Server, expectedStatus int) []workflow.Profile { client := http.DefaultClient theURL := server.URL + "/management/v1/profiles" @@ -189,11 +324,11 @@ func testListHTTP(t *testing.T, svc Service, server *httptest.Server, expectedSt return profiles } -func testAddHTTP(t *testing.T, svc Service, server *httptest.Server, profile []byte, expectedStatus int) { - body := &nopCloser{bytes.NewBuffer(profile)} +func testAddHTTP(endpoint string, t *testing.T, svc Service, server *httptest.Server, data []byte, expectedStatus int) io.Reader { + body := &nopCloser{bytes.NewBuffer(data)} client := http.DefaultClient - theURL := server.URL + "/management/v1/profiles" + theURL := server.URL + "/management/v1/" + endpoint resp, err := client.Post(theURL, "application/json", body) if err != nil { t.Fatal(err) @@ -203,6 +338,8 @@ func testAddHTTP(t *testing.T, svc Service, server *httptest.Server, profile []b io.Copy(os.Stdout, resp.Body) t.Fatal("expected", expectedStatus, "got", resp.StatusCode) } + + return resp.Body } func TestFetchDEPDevices(t *testing.T) {