diff --git a/api/http/internal.go b/api/http/internal.go index b8e2c5b5..aa06aba6 100644 --- a/api/http/internal.go +++ b/api/http/internal.go @@ -87,3 +87,17 @@ func (h InternalController) sendMenderCommand(c *gin.Context, msgType string) { c.JSON(http.StatusAccepted, nil) } + +func (h InternalController) DeleteTenant(c *gin.Context) { + ctx := c.Request.Context() + tenantID := c.Param("tenantId") + + err := h.app.DeleteTenant(ctx, tenantID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + } + + c.Status(http.StatusNoContent) +} diff --git a/api/http/internal_test.go b/api/http/internal_test.go index c86635f3..a39dbce3 100644 --- a/api/http/internal_test.go +++ b/api/http/internal_test.go @@ -270,3 +270,67 @@ func TestInternalSendInventory(t *testing.T) { }) } } + +func TestDeleteTenant(t *testing.T) { + t.Parallel() + const tenantID = "123456789012345678901234" + + testCases := []struct { + Name string + Request *http.Request + Error error + Status int + }{{ + Name: "ok", + + Request: func() *http.Request { + repl := strings.NewReplacer( + ":tenantId", tenantID, + ) + req, _ := http.NewRequest("DELETE", + "http://localhost"+repl.Replace(APIURLInternalTenant), + nil, + ) + return req + }(), + + Status: http.StatusNoContent, + }, { + Name: "error, internal server error", + + Request: func() *http.Request { + repl := strings.NewReplacer( + ":tenantId", tenantID, + ) + req, _ := http.NewRequest("DELETE", + "http://localhost"+repl.Replace(APIURLInternalTenant), + nil, + ) + return req + }(), + + Error: errors.New("error"), + Status: http.StatusInternalServerError, + }} + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + app := &app_mocks.App{} + defer app.AssertExpectations(t) + + router, _ := NewRouter(app, nil, nil) + s := httptest.NewServer(router) + defer s.Close() + + app.On("DeleteTenant", + mock.MatchedBy(func(_ context.Context) bool { + return true + }), + tenantID, + ).Return(tc.Error) + + w := httptest.NewRecorder() + router.ServeHTTP(w, tc.Request) + assert.Equal(t, tc.Status, w.Code) + }) + } +} diff --git a/api/http/router.go b/api/http/router.go index 03b14edc..072c3eed 100644 --- a/api/http/router.go +++ b/api/http/router.go @@ -38,6 +38,7 @@ const ( APIURLInternalAlive = APIURLInternal + "/alive" APIURLInternalHealth = APIURLInternal + "/health" APIURLInternalShutdown = APIURLInternal + "/shutdown" + APIURLInternalTenant = APIURLInternal + "/tenants/:tenantId" APIURLInternalDevices = APIURLInternal + "/tenants/:tenantId/devices" APIURLInternalDevicesID = APIURLInternal + "/tenants/:tenantId/devices/:deviceId" @@ -89,6 +90,7 @@ func NewRouter( router.GET(APIURLInternalShutdown, status.Shutdown) internal := NewInternalController(app, natsClient) + router.DELETE(APIURLInternalTenant, internal.DeleteTenant) router.POST(APIURLInternalDevicesIDCheckUpdate, internal.CheckUpdate) router.POST(APIURLInternalDevicesIDSendInventory, internal.SendInventory) diff --git a/app/app.go b/app/app.go index 7c09cb55..0a1f9a95 100644 --- a/app/app.go +++ b/app/app.go @@ -24,6 +24,8 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" + "github.com/mendersoftware/go-lib-micro/identity" + "github.com/mendersoftware/deviceconnect/client/inventory" "github.com/mendersoftware/deviceconnect/client/workflows" "github.com/mendersoftware/deviceconnect/model" @@ -56,6 +58,7 @@ type App interface { GetControlRecorder(ctx context.Context, sessionID string) io.Writer DownloadFile(ctx context.Context, userID string, deviceID string, path string) error UploadFile(ctx context.Context, userID string, deviceID string, path string) error + DeleteTenant(ctx context.Context, tenantID string) error Shutdown(timeout time.Duration) ShutdownDone() RegisterShutdownCancel(context.CancelFunc) uint32 @@ -361,3 +364,10 @@ func (a *app) UnregisterShutdownCancel(id uint32) { defer a.shutdownCancelsM.Unlock() delete(a.shutdownCancels, id) } + +func (d *app) DeleteTenant(ctx context.Context, tenantID string) error { + tenantCtx := identity.WithContext(ctx, &identity.Identity{ + Tenant: tenantID, + }) + return d.store.DeleteTenant(tenantCtx, tenantID) +} diff --git a/app/app_test.go b/app/app_test.go index 15f0181d..8fb5b76f 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -17,6 +17,7 @@ package app import ( "context" "errors" + "fmt" "io" "testing" "time" @@ -30,6 +31,7 @@ import ( wf_mocks "github.com/mendersoftware/deviceconnect/client/workflows/mocks" "github.com/mendersoftware/deviceconnect/model" store_mocks "github.com/mendersoftware/deviceconnect/store/mocks" + "github.com/mendersoftware/go-lib-micro/identity" ) func TestHealthCheck(t *testing.T) { @@ -886,3 +888,49 @@ func TestShutdownCancels(t *testing.T) { app.ShutdownDone() } + +func TestDeleteTenant(t *testing.T) { + t.Parallel() + + testCases := []struct { + tenantId string + + dbErr error + outErr string + }{ + { + tenantId: "tenant1", + dbErr: errors.New("error"), + }, + { + tenantId: "tenant2", + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("tc %d", i), func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + ds := new(store_mocks.DataStore) + defer ds.AssertExpectations(t) + ds.On("DeleteTenant", + mock.MatchedBy(func(ctx context.Context) bool { + ident := identity.FromContext(ctx) + return assert.NotNil(t, ident) && + assert.Equal(t, tc.tenantId, ident.Tenant) + }), + tc.tenantId, + ).Return(tc.dbErr) + app := New(ds, nil, nil, Config{}) + err := app.DeleteTenant(ctx, tc.tenantId) + + if tc.dbErr != nil { + assert.EqualError(t, err, tc.dbErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/app/mocks/App.go b/app/mocks/App.go index 8211b9f9..25d4c5c7 100644 --- a/app/mocks/App.go +++ b/app/mocks/App.go @@ -46,6 +46,20 @@ func (_m *App) DeleteDevice(ctx context.Context, tenantID string, deviceID strin return r0 } +// DeleteTenant provides a mock function with given fields: ctx, tenantID +func (_m *App) DeleteTenant(ctx context.Context, tenantID string) error { + ret := _m.Called(ctx, tenantID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, tenantID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DownloadFile provides a mock function with given fields: ctx, userID, deviceID, path func (_m *App) DownloadFile(ctx context.Context, userID string, deviceID string, path string) error { ret := _m.Called(ctx, userID, deviceID, path) diff --git a/docs/internal_api.yml b/docs/internal_api.yml index dddde34d..21840955 100644 --- a/docs/internal_api.yml +++ b/docs/internal_api.yml @@ -68,6 +68,25 @@ paths: schema: $ref: '#/components/schemas/Error' + /tenants/{tenantId}: + delete: + operationId: "Delete Tenant" + tags: + - Internal API + summary: Delete all the data for given tenant. + parameters: + - in: path + name: tenantId + schema: + type: string + required: true + description: ID of tenant. + responses: + 204: + description: All the tenant data have been successfully deleted. + 500: + $ref: '#/components/responses/InternalServerError' + /tenants/{tenantId}/devices: post: tags: diff --git a/store/datastore.go b/store/datastore.go index 10b4dc10..08f36816 100644 --- a/store/datastore.go +++ b/store/datastore.go @@ -39,6 +39,7 @@ type DataStore interface { InsertSessionRecording(ctx context.Context, sessionID string, sessionBytes []byte) error InsertControlRecording(ctx context.Context, sessionID string, sessionBytes []byte) error DeleteSession(ctx context.Context, sessionID string) (*model.Session, error) + DeleteTenant(ctx context.Context, tenantID string) error Close() error } diff --git a/store/mocks/DataStore.go b/store/mocks/DataStore.go index 6a8174f0..876fea4b 100644 --- a/store/mocks/DataStore.go +++ b/store/mocks/DataStore.go @@ -95,6 +95,20 @@ func (_m *DataStore) DeleteSession(ctx context.Context, sessionID string) (*mode return r0, r1 } +// DeleteTenant provides a mock function with given fields: ctx, tenantID +func (_m *DataStore) DeleteTenant(ctx context.Context, tenantID string) error { + ret := _m.Called(ctx, tenantID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, tenantID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // GetDevice provides a mock function with given fields: ctx, tenantID, deviceID func (_m *DataStore) GetDevice(ctx context.Context, tenantID string, deviceID string) (*model.Device, error) { ret := _m.Called(ctx, tenantID, deviceID) diff --git a/store/mongo/datastore_mongo.go b/store/mongo/datastore_mongo.go index b4df9454..113fb917 100644 --- a/store/mongo/datastore_mongo.go +++ b/store/mongo/datastore_mongo.go @@ -612,3 +612,19 @@ func (db *DataStoreMongo) DropDatabase() error { err := db.client.Database(DbName).Drop(ctx) return err } + +func (db *DataStoreMongo) DeleteTenant(ctx context.Context, tenantID string) error { + database := db.client.Database(DbName) + collectionNames, err := database.ListCollectionNames(ctx, mopts.ListCollectionsOptions{}) + if err != nil { + return err + } + for _, collName := range collectionNames { + collection := database.Collection(collName) + _, e := collection.DeleteMany(ctx, mstore.WithTenantID(ctx, bson.D{})) + if e != nil { + return e + } + } + return nil +} diff --git a/store/mongo/datastore_mongo_test.go b/store/mongo/datastore_mongo_test.go index 212e6713..2553c644 100644 --- a/store/mongo/datastore_mongo_test.go +++ b/store/mongo/datastore_mongo_test.go @@ -29,6 +29,7 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" mopts "go.mongodb.org/mongo-driver/mongo/options" @@ -689,3 +690,31 @@ func TestSetSessionRecording(t *testing.T) { }) } } + +func TestDeleteTenant(t *testing.T) { + if testing.Short() { + t.Skip("skipping TestDeleteTenant in short mode.") + } + + const tenant = "foo" + deviceID := uuid.NewSHA1(uuid.NameSpaceDNS, []byte("mender.io")).String() + + ctx := identity.WithContext(context.Background(), + &identity.Identity{ + Tenant: tenant, + }, + ) + + d := &DataStoreMongo{ + client: db.Client(), + } + err := d.ProvisionDevice(ctx, tenant, deviceID) + require.NoError(t, err) + + err = d.DeleteTenant(ctx, tenant) + assert.NoError(t, err) + + dev, err := d.GetDevice(ctx, tenant, deviceID) + assert.Nil(t, dev) + assert.NoError(t, err) +}