diff --git a/internal/controller/httpapi/ui_noui.go b/internal/controller/httpapi/ui_noui.go index fa0522921..ca749d7cd 100644 --- a/internal/controller/httpapi/ui_noui.go +++ b/internal/controller/httpapi/ui_noui.go @@ -5,9 +5,10 @@ package httpapi import ( "net/http" + "github.com/gin-gonic/gin" + "github.com/device-management-toolkit/console/config" "github.com/device-management-toolkit/console/pkg/logger" - "github.com/gin-gonic/gin" ) func HasUI() bool { return false } diff --git a/internal/controller/httpapi/v1/boot.go b/internal/controller/httpapi/v1/boot.go new file mode 100644 index 000000000..f63a0a36b --- /dev/null +++ b/internal/controller/httpapi/v1/boot.go @@ -0,0 +1,43 @@ +package v1 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/device-management-toolkit/console/internal/entity/dto/v1" +) + +func (r *deviceManagementRoutes) getRemoteEraseCapabilities(c *gin.Context) { + guid := c.Param("guid") + + capabilities, err := r.d.GetRemoteEraseCapabilities(c.Request.Context(), guid) + if err != nil { + r.l.Error(err, "http - v1 - getRemoteEraseCapabilities") + ErrorResponse(c, err) + + return + } + + c.JSON(http.StatusOK, capabilities) +} + +func (r *deviceManagementRoutes) setRemoteEraseOptions(c *gin.Context) { + guid := c.Param("guid") + + var req dto.RemoteEraseRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, err) + + return + } + + if err := r.d.SetRemoteEraseOptions(c.Request.Context(), guid, req); err != nil { + r.l.Error(err, "http - v1 - setRemoteEraseOptions") + ErrorResponse(c, err) + + return + } + + c.JSON(http.StatusOK, nil) +} diff --git a/internal/controller/httpapi/v1/boot_test.go b/internal/controller/httpapi/v1/boot_test.go new file mode 100644 index 000000000..9dd1972a8 --- /dev/null +++ b/internal/controller/httpapi/v1/boot_test.go @@ -0,0 +1,147 @@ +package v1 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/mocks" +) + +func TestGetRemoteEraseCapabilities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mock func(m *mocks.MockDeviceManagementFeature) + expectedCode int + response interface{} + }{ + { + name: "getRemoteEraseCapabilities - successful retrieval", + mock: func(m *mocks.MockDeviceManagementFeature) { + m.EXPECT().GetRemoteEraseCapabilities(context.Background(), "valid-guid"). + Return(dto.BootCapabilities{SecureEraseAllSSDs: true}, nil) + }, + expectedCode: http.StatusOK, + response: dto.BootCapabilities{SecureEraseAllSSDs: true}, + }, + { + name: "getRemoteEraseCapabilities - service failure", + mock: func(m *mocks.MockDeviceManagementFeature) { + m.EXPECT().GetRemoteEraseCapabilities(context.Background(), "valid-guid"). + Return(dto.BootCapabilities{}, ErrGeneral) + }, + expectedCode: http.StatusInternalServerError, + response: nil, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deviceManagement, engine := deviceManagementTest(t) + tc.mock(deviceManagement) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/amt/boot/remoteErase/valid-guid", http.NoBody) + require.NoError(t, err) + + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + require.Equal(t, tc.expectedCode, w.Code) + + if tc.expectedCode == http.StatusOK { + jsonBytes, _ := json.Marshal(tc.response) + require.Equal(t, string(jsonBytes), w.Body.String()) + } + }) + } +} + +func TestSetRemoteEraseOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestBody interface{} + mock func(m *mocks.MockDeviceManagementFeature) + expectedCode int + }{ + { + name: "setRemoteEraseOptions - successful", + requestBody: dto.RemoteEraseRequest{SecureEraseAllSSDs: true, TPMClear: true}, + mock: func(m *mocks.MockDeviceManagementFeature) { + m.EXPECT().SetRemoteEraseOptions(context.Background(), "valid-guid", dto.RemoteEraseRequest{SecureEraseAllSSDs: true, TPMClear: true}). + Return(nil) + }, + expectedCode: http.StatusOK, + }, + { + name: "setRemoteEraseOptions - forwards full payload", + requestBody: dto.RemoteEraseRequest{ + SecureEraseAllSSDs: true, + TPMClear: true, + RestoreBIOSToEOM: true, + UnconfigureCSME: true, + SSDPassword: "s3cr3t", + }, + mock: func(m *mocks.MockDeviceManagementFeature) { + m.EXPECT().SetRemoteEraseOptions(context.Background(), "valid-guid", dto.RemoteEraseRequest{ + SecureEraseAllSSDs: true, + TPMClear: true, + RestoreBIOSToEOM: true, + UnconfigureCSME: true, + SSDPassword: "s3cr3t", + }). + Return(nil) + }, + expectedCode: http.StatusOK, + }, + { + name: "setRemoteEraseOptions - invalid JSON payload", + requestBody: "invalid-json", + mock: func(_ *mocks.MockDeviceManagementFeature) { + }, + expectedCode: http.StatusInternalServerError, + }, + { + name: "setRemoteEraseOptions - service failure", + requestBody: dto.RemoteEraseRequest{UnconfigureCSME: true}, + mock: func(m *mocks.MockDeviceManagementFeature) { + m.EXPECT().SetRemoteEraseOptions(context.Background(), "valid-guid", dto.RemoteEraseRequest{UnconfigureCSME: true}). + Return(ErrGeneral) + }, + expectedCode: http.StatusInternalServerError, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deviceManagement, engine := deviceManagementTest(t) + tc.mock(deviceManagement) + + reqBody, _ := json.Marshal(tc.requestBody) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/amt/boot/remoteErase/valid-guid", bytes.NewBuffer(reqBody)) + require.NoError(t, err) + + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + require.Equal(t, tc.expectedCode, w.Code) + }) + } +} diff --git a/internal/controller/httpapi/v1/devicemanagement.go b/internal/controller/httpapi/v1/devicemanagement.go index 559605935..25d63332f 100644 --- a/internal/controller/httpapi/v1/devicemanagement.go +++ b/internal/controller/httpapi/v1/devicemanagement.go @@ -20,60 +20,75 @@ func NewAmtRoutes(handler *gin.RouterGroup, d devices.Feature, amt amtexplorer.F r := &deviceManagementRoutes{d, amt, e, l} h := handler.Group("/amt") - { - h.GET("version/:guid", r.getVersion) - - h.GET("features/:guid", r.getFeatures) - h.POST("features/:guid", r.setFeatures) - - h.GET("alarmOccurrences/:guid", r.getAlarmOccurrences) - h.POST("alarmOccurrences/:guid", r.createAlarmOccurrences) - h.DELETE("alarmOccurrences/:guid", r.deleteAlarmOccurrences) - - h.GET("hardwareInfo/:guid", r.getHardwareInfo) - h.GET("diskInfo/:guid", r.getDiskInfo) - h.GET("power/state/:guid", r.getPowerState) - h.POST("power/action/:guid", r.powerAction) - h.POST("power/bootOptions/:guid", r.setBootOptions) - h.POST("power/bootoptions/:guid", r.setBootOptions) - h.GET("power/bootSources/:guid", r.getBootSources) - h.GET("power/capabilities/:guid", r.getPowerCapabilities) - - h.GET("log/audit/:guid", r.getAuditLog) - h.GET("log/audit/:guid/download", r.downloadAuditLog) - h.GET("log/event/:guid", r.getEventLog) - h.GET("log/event/:guid/download", r.downloadEventLog) - h.GET("generalSettings/:guid", r.getGeneralSettings) - - h.GET("userConsentCode/cancel/:guid", r.cancelUserConsentCode) - h.GET("userConsentCode/:guid", r.getUserConsentCode) - h.POST("userConsentCode/:guid", r.sendConsentCode) - - h.GET("networkSettings/:guid", r.getNetworkSettings) - h.GET("networkSettings/wired/:guid", r.getWiredNetworkSettings) - h.PATCH("networkSettings/wired/:guid", r.patchWiredNetworkSettings) - h.GET("networkSettings/wireless/state/:guid", r.getWirelessState) - h.POST("networkSettings/wireless/state/:guid", r.requestWirelessStateChange) - h.GET("networkSettings/wireless/profileSync/:guid", r.getWirelessProfileSync) - h.POST("networkSettings/wireless/profileSync/:guid", r.setWirelessProfileSync) - h.GET("networkSettings/wireless/profile/:guid", r.getWirelessProfiles) - h.POST("networkSettings/wireless/profile/:guid", r.addWirelessProfile) - h.PATCH("networkSettings/wireless/profile/:guid", r.updateWirelessProfile) - h.DELETE("networkSettings/wireless/profile/:guid/:profileName", r.deleteWirelessProfile) - - h.GET("explorer", r.getCallList) - h.GET("explorer/:guid/:call", r.executeCall) - h.GET("tls/:guid", r.getTLSSettingData) - - h.GET("certificates/:guid", r.getCertificates) - h.POST("certificates/:guid", r.addCertificate) - h.DELETE("certificates/:guid/:instanceId", r.deleteCertificate) - - // KVM display settings - h.GET("kvm/displays/:guid", r.getKVMDisplays) - h.PUT("kvm/displays/:guid", r.setKVMDisplays) - - // Network link preference - h.POST("network/linkPreference/:guid", r.setLinkPreference) - } + r.registerCoreRoutes(h) + r.registerPowerAndLogRoutes(h) + r.registerNetworkRoutes(h) + r.registerExplorerAndCertificateRoutes(h) + r.registerKVMAndLinkRoutes(h) +} + +func (r *deviceManagementRoutes) registerCoreRoutes(h *gin.RouterGroup) { + h.GET("version/:guid", r.getVersion) + + h.GET("features/:guid", r.getFeatures) + h.POST("features/:guid", r.setFeatures) + + h.GET("alarmOccurrences/:guid", r.getAlarmOccurrences) + h.POST("alarmOccurrences/:guid", r.createAlarmOccurrences) + h.DELETE("alarmOccurrences/:guid", r.deleteAlarmOccurrences) + + h.GET("boot/remoteErase/:guid", r.getRemoteEraseCapabilities) + h.POST("boot/remoteErase/:guid", r.setRemoteEraseOptions) + h.GET("hardwareInfo/:guid", r.getHardwareInfo) + h.GET("diskInfo/:guid", r.getDiskInfo) + h.GET("generalSettings/:guid", r.getGeneralSettings) + + h.GET("userConsentCode/cancel/:guid", r.cancelUserConsentCode) + h.GET("userConsentCode/:guid", r.getUserConsentCode) + h.POST("userConsentCode/:guid", r.sendConsentCode) +} + +func (r *deviceManagementRoutes) registerPowerAndLogRoutes(h *gin.RouterGroup) { + h.GET("power/state/:guid", r.getPowerState) + h.POST("power/action/:guid", r.powerAction) + h.POST("power/bootOptions/:guid", r.setBootOptions) + h.POST("power/bootoptions/:guid", r.setBootOptions) + h.GET("power/bootSources/:guid", r.getBootSources) + h.GET("power/capabilities/:guid", r.getPowerCapabilities) + + h.GET("log/audit/:guid", r.getAuditLog) + h.GET("log/audit/:guid/download", r.downloadAuditLog) + h.GET("log/event/:guid", r.getEventLog) + h.GET("log/event/:guid/download", r.downloadEventLog) +} + +func (r *deviceManagementRoutes) registerNetworkRoutes(h *gin.RouterGroup) { + h.GET("networkSettings/:guid", r.getNetworkSettings) + h.GET("networkSettings/wired/:guid", r.getWiredNetworkSettings) + h.PATCH("networkSettings/wired/:guid", r.patchWiredNetworkSettings) + h.GET("networkSettings/wireless/state/:guid", r.getWirelessState) + h.POST("networkSettings/wireless/state/:guid", r.requestWirelessStateChange) + h.GET("networkSettings/wireless/profileSync/:guid", r.getWirelessProfileSync) + h.POST("networkSettings/wireless/profileSync/:guid", r.setWirelessProfileSync) + h.GET("networkSettings/wireless/profile/:guid", r.getWirelessProfiles) + h.POST("networkSettings/wireless/profile/:guid", r.addWirelessProfile) + h.PATCH("networkSettings/wireless/profile/:guid", r.updateWirelessProfile) + h.DELETE("networkSettings/wireless/profile/:guid/:profileName", r.deleteWirelessProfile) +} + +func (r *deviceManagementRoutes) registerExplorerAndCertificateRoutes(h *gin.RouterGroup) { + h.GET("explorer", r.getCallList) + h.GET("explorer/:guid/:call", r.executeCall) + h.GET("tls/:guid", r.getTLSSettingData) + + h.GET("certificates/:guid", r.getCertificates) + h.POST("certificates/:guid", r.addCertificate) + h.DELETE("certificates/:guid/:instanceId", r.deleteCertificate) +} + +func (r *deviceManagementRoutes) registerKVMAndLinkRoutes(h *gin.RouterGroup) { + h.GET("kvm/displays/:guid", r.getKVMDisplays) + h.PUT("kvm/displays/:guid", r.setKVMDisplays) + + h.POST("network/linkPreference/:guid", r.setLinkPreference) } diff --git a/internal/controller/httpapi/v1/devicemanagement_test.go b/internal/controller/httpapi/v1/devicemanagement_test.go index 8b997ca27..9a35a405b 100644 --- a/internal/controller/httpapi/v1/devicemanagement_test.go +++ b/internal/controller/httpapi/v1/devicemanagement_test.go @@ -133,7 +133,7 @@ func TestDeviceManagement(t *testing.T) { OCR: false, OptInState: 0, Redirection: false, - RemoteErase: false, + RPE: false, UserConsent: "", WinREBootSupported: false, }, diff --git a/internal/controller/httpapi/v1/features.go b/internal/controller/httpapi/v1/features.go index 5c95394a3..27df7c7ac 100644 --- a/internal/controller/httpapi/v1/features.go +++ b/internal/controller/httpapi/v1/features.go @@ -45,7 +45,8 @@ func (r *deviceManagementRoutes) getFeatures(c *gin.Context) { HTTPSBootSupported: features.HTTPSBootSupported, WinREBootSupported: features.WinREBootSupported, LocalPBABootSupported: features.LocalPBABootSupported, - RemoteErase: features.RemoteErase, + RPE: features.RPE, + RPESupported: features.RPESupported, } c.JSON(http.StatusOK, v1Features) diff --git a/internal/controller/openapi/devicemanagement.go b/internal/controller/openapi/devicemanagement.go index 1aad3ae87..88d49388d 100644 --- a/internal/controller/openapi/devicemanagement.go +++ b/internal/controller/openapi/devicemanagement.go @@ -333,6 +333,30 @@ func (f *FuegoAdapter) registerPowerRoutes() { fuego.OptionPath("guid", "Device GUID"), protectedRouteOptions(), ) + + fuego.Get(f.server, "/api/v1/amt/boot/capabilities/{guid}", f.getBootCapabilities, + fuego.OptionTags("Device Management"), + fuego.OptionSummary("Get Boot Capabilities"), + fuego.OptionDescription("Read AMT_BootCapabilities.PlatformErase to determine Remote Platform Erase (RPE) support in the BIOS"), + fuego.OptionPath("guid", "Device GUID"), + protectedRouteOptions(), + ) + + fuego.Get(f.server, "/api/v1/amt/boot/remoteErase/{guid}", f.getRemoteEraseCapabilities, + fuego.OptionTags("Device Management"), + fuego.OptionSummary("Get Remote Erase Capabilities"), + fuego.OptionDescription("Retrieve Remote Platform Erase capabilities for a device"), + fuego.OptionPath("guid", "Device GUID"), + protectedRouteOptions(), + ) + + fuego.Post(f.server, "/api/v1/amt/boot/remoteErase/{guid}", f.setRemoteEraseOptions, + fuego.OptionTags("Device Management"), + fuego.OptionSummary("Set Remote Erase Options"), + fuego.OptionDescription("Trigger Remote Platform Erase on a device; at least one erase option must be selected"), + fuego.OptionPath("guid", "Device GUID"), + protectedRouteOptions(), + ) } func (f *FuegoAdapter) registerLogsAndAlarmRoutes() { @@ -636,6 +660,18 @@ func (f *FuegoAdapter) getPowerCapabilities(_ fuego.ContextNoBody) (dto.PowerCap return dto.PowerCapabilities{}, nil } +func (f *FuegoAdapter) getBootCapabilities(_ fuego.ContextNoBody) (dto.BootCapabilities, error) { + return dto.BootCapabilities{}, nil +} + +func (f *FuegoAdapter) getRemoteEraseCapabilities(_ fuego.ContextNoBody) (dto.BootCapabilities, error) { + return dto.BootCapabilities{}, nil +} + +func (f *FuegoAdapter) setRemoteEraseOptions(_ fuego.ContextWithBody[dto.RemoteEraseRequest]) (NoContentResponse, error) { + return NoContentResponse{}, nil +} + func (f *FuegoAdapter) getAlarmOccurrences(_ fuego.ContextNoBody) ([]dto.AlarmClockOccurrence, error) { return []dto.AlarmClockOccurrence{}, nil } diff --git a/internal/controller/openapi/devicemanagement_test.go b/internal/controller/openapi/devicemanagement_test.go new file mode 100644 index 000000000..6ea2d10cb --- /dev/null +++ b/internal/controller/openapi/devicemanagement_test.go @@ -0,0 +1,48 @@ +package openapi + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/usecase" + "github.com/device-management-toolkit/console/pkg/logger" +) + +func newTestAdapter() *FuegoAdapter { + log := logger.New("error") + + return NewFuegoAdapter(usecase.Usecases{}, log) +} + +func TestGetBootCapabilities(t *testing.T) { + t.Parallel() + + f := newTestAdapter() + + result, err := f.getBootCapabilities(nil) + + require.NoError(t, err) + require.Equal(t, dto.BootCapabilities{}, result) +} + +func TestRegisterPowerRoutes_IncludesBootEndpoints(t *testing.T) { + t.Parallel() + + f := newTestAdapter() + f.RegisterDeviceManagementRoutes() + + specBytes, err := f.GetOpenAPISpec() + require.NoError(t, err) + + var spec map[string]interface{} + require.NoError(t, json.Unmarshal(specBytes, &spec)) + + paths, ok := spec["paths"].(map[string]interface{}) + require.True(t, ok) + + require.Contains(t, paths, "/api/v1/amt/boot/capabilities/{guid}", "boot capabilities route should be registered") + require.Contains(t, paths, "/api/v1/amt/boot/remoteErase/{guid}", "set RPE enabled route should be registered") +} diff --git a/internal/controller/ws/v1/interface.go b/internal/controller/ws/v1/interface.go index 2485271ab..6b17ea71c 100644 --- a/internal/controller/ws/v1/interface.go +++ b/internal/controller/ws/v1/interface.go @@ -76,6 +76,9 @@ type Feature interface { GetDeviceCertificate(c context.Context, guid string) (dto.Certificate, error) AddCertificate(c context.Context, guid string, certInfo dto.CertInfo) (string, error) DeleteCertificate(c context.Context, guid, instanceID string) error + GetBootCapabilities(ctx context.Context, guid string) (dto.BootCapabilities, error) + GetRemoteEraseCapabilities(ctx context.Context, guid string) (dto.BootCapabilities, error) + SetRemoteEraseOptions(ctx context.Context, guid string, req dto.RemoteEraseRequest) error GetBootSourceSetting(ctx context.Context, guid string) ([]dto.BootSources, error) GetKVMScreenSettings(c context.Context, guid string) (dto.KVMScreenSettings, error) SetKVMScreenSettings(c context.Context, guid string, req dto.KVMScreenSettingsRequest) (dto.KVMScreenSettings, error) diff --git a/internal/entity/dto/v1/bootcapabilities.go b/internal/entity/dto/v1/bootcapabilities.go new file mode 100644 index 000000000..dc361efb5 --- /dev/null +++ b/internal/entity/dto/v1/bootcapabilities.go @@ -0,0 +1,20 @@ +package dto + +type RPERequest struct { + Enabled bool `json:"enabled"` +} + +type RemoteEraseRequest struct { + SecureEraseAllSSDs bool `json:"secureEraseAllSSDs"` + TPMClear bool `json:"tpmClear"` + RestoreBIOSToEOM bool `json:"restoreBIOSToEOM"` + UnconfigureCSME bool `json:"unconfigureCSME"` + SSDPassword string `json:"ssdPassword,omitempty"` +} + +type BootCapabilities struct { + SecureEraseAllSSDs bool `json:"secureEraseAllSSDs"` + TPMClear bool `json:"tpmClear"` + RestoreBIOSToEOM bool `json:"restoreBIOSToEOM"` + UnconfigureCSME bool `json:"unconfigureCSME"` +} diff --git a/internal/entity/dto/v1/features.go b/internal/entity/dto/v1/features.go index 426a07746..b347841fc 100644 --- a/internal/entity/dto/v1/features.go +++ b/internal/entity/dto/v1/features.go @@ -23,7 +23,8 @@ type Features struct { HTTPSBootSupported bool `json:"httpsBootSupported" example:"true"` WinREBootSupported bool `json:"winREBootSupported" example:"true"` LocalPBABootSupported bool `json:"localPBABootSupported" example:"true"` - RemoteErase bool `json:"remoteErase" example:"true"` + RPE bool `json:"rpe" example:"true"` + RPESupported bool `json:"rpeSupported" example:"true"` } type FeaturesRequest struct { @@ -32,5 +33,5 @@ type FeaturesRequest struct { EnableIDER bool `json:"enableIDER" example:"true"` EnableKVM bool `json:"enableKVM" example:"true"` OCR bool `json:"ocr" example:"true"` - RemoteErase bool `json:"remoteErase" example:"true"` + RPE bool `json:"rpe" example:"true"` } diff --git a/internal/entity/dto/v1/getfeatures.go b/internal/entity/dto/v1/getfeatures.go index d62dd5698..09e218a6a 100644 --- a/internal/entity/dto/v1/getfeatures.go +++ b/internal/entity/dto/v1/getfeatures.go @@ -12,5 +12,6 @@ type GetFeaturesResponse struct { HTTPSBootSupported bool `json:"httpsBootSupported" binding:"required" example:"false"` WinREBootSupported bool `json:"winREBootSupported" binding:"required" example:"false"` LocalPBABootSupported bool `json:"localPBABootSupported" binding:"required" example:"false"` - RemoteErase bool `json:"remoteErase" binding:"required" example:"false"` + RPE bool `json:"rpe" binding:"required" example:"false"` + RPESupported bool `json:"rpeSupported" example:"false"` } diff --git a/internal/entity/dto/v2/features.go b/internal/entity/dto/v2/features.go index 8152ec245..046089aac 100644 --- a/internal/entity/dto/v2/features.go +++ b/internal/entity/dto/v2/features.go @@ -24,5 +24,6 @@ type Features struct { HTTPSBootSupported bool `json:"httpBootSupported,omitempty" example:"true"` WinREBootSupported bool `json:"winREBootSupported,omitempty" example:"true"` LocalPBABootSupported bool `json:"localPBABootSupported,omitempty" example:"true"` - RemoteErase bool `json:"remoteErase" example:"true"` + RPE bool `json:"rpe" example:"true"` + RPESupported bool `json:"rpeSupported" example:"true"` } diff --git a/internal/mocks/devicemanagement_mocks.go b/internal/mocks/devicemanagement_mocks.go index 7f961ceb4..a5266d57d 100644 --- a/internal/mocks/devicemanagement_mocks.go +++ b/internal/mocks/devicemanagement_mocks.go @@ -239,18 +239,18 @@ func (mr *MockRedirectionMockRecorder) RedirectSend(ctx, deviceConnection, messa } // SetupWsmanClient mocks base method. -func (m *MockRedirection) SetupWsmanClient(device entity.Device, isRedirection, logMessages bool) (wsman0.Messages, error) { +func (m *MockRedirection) SetupWsmanClient(ctx context.Context, device entity.Device, isRedirection, logMessages bool) (wsman0.Messages, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetupWsmanClient", device, isRedirection, logMessages) + ret := m.ctrl.Call(m, "SetupWsmanClient", ctx, device, isRedirection, logMessages) ret0, _ := ret[0].(wsman0.Messages) ret1, _ := ret[1].(error) return ret0, ret1 } // SetupWsmanClient indicates an expected call of SetupWsmanClient. -func (mr *MockRedirectionMockRecorder) SetupWsmanClient(device, isRedirection, logMessages any) *gomock.Call { +func (mr *MockRedirectionMockRecorder) SetupWsmanClient(ctx, device, isRedirection, logMessages any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetupWsmanClient", reflect.TypeOf((*MockRedirection)(nil).SetupWsmanClient), device, isRedirection, logMessages) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetupWsmanClient", reflect.TypeOf((*MockRedirection)(nil).SetupWsmanClient), ctx, device, isRedirection, logMessages) } // MockDeviceManagementRepository is a mock of Repository interface. @@ -880,6 +880,21 @@ func (mr *MockDeviceManagementFeatureMockRecorder) GetPowerState(ctx, guid any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPowerState", reflect.TypeOf((*MockDeviceManagementFeature)(nil).GetPowerState), ctx, guid) } +// GetRemoteEraseCapabilities mocks base method. +func (m *MockDeviceManagementFeature) GetRemoteEraseCapabilities(ctx context.Context, guid string) (dto.BootCapabilities, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRemoteEraseCapabilities", ctx, guid) + ret0, _ := ret[0].(dto.BootCapabilities) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRemoteEraseCapabilities indicates an expected call of GetRemoteEraseCapabilities. +func (mr *MockDeviceManagementFeatureMockRecorder) GetRemoteEraseCapabilities(ctx, guid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRemoteEraseCapabilities", reflect.TypeOf((*MockDeviceManagementFeature)(nil).GetRemoteEraseCapabilities), ctx, guid) +} + // GetTLSSettingData mocks base method. func (m *MockDeviceManagementFeature) GetTLSSettingData(c context.Context, guid string) ([]dto.SettingDataResponse, error) { m.ctrl.T.Helper() @@ -1135,6 +1150,20 @@ func (mr *MockDeviceManagementFeatureMockRecorder) SetLinkPreference(c, guid, re return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLinkPreference", reflect.TypeOf((*MockDeviceManagementFeature)(nil).SetLinkPreference), c, guid, req) } +// SetRemoteEraseOptions mocks base method. +func (m *MockDeviceManagementFeature) SetRemoteEraseOptions(ctx context.Context, guid string, req dto.RemoteEraseRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetRemoteEraseOptions", ctx, guid, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetRemoteEraseOptions indicates an expected call of SetRemoteEraseOptions. +func (mr *MockDeviceManagementFeatureMockRecorder) SetRemoteEraseOptions(ctx, guid, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRemoteEraseOptions", reflect.TypeOf((*MockDeviceManagementFeature)(nil).SetRemoteEraseOptions), ctx, guid, req) +} + // SetWirelessProfileSync mocks base method. func (m *MockDeviceManagementFeature) SetWirelessProfileSync(c context.Context, guid string, req dto.WirelessProfileSyncRequest) (dto.WirelessProfileSyncResponse, error) { m.ctrl.T.Helper() diff --git a/internal/mocks/wsman_mocks.go b/internal/mocks/wsman_mocks.go index 0b864fa2c..439bf1432 100644 --- a/internal/mocks/wsman_mocks.go +++ b/internal/mocks/wsman_mocks.go @@ -303,6 +303,21 @@ func (mr *MockManagementMockRecorder) GetAuditLog(startIndex any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuditLog", reflect.TypeOf((*MockManagement)(nil).GetAuditLog), startIndex) } +// GetBootCapabilities mocks base method. +func (m *MockManagement) GetBootCapabilities() (boot.BootCapabilitiesResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBootCapabilities") + ret0, _ := ret[0].(boot.BootCapabilitiesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBootCapabilities indicates an expected call of GetBootCapabilities. +func (mr *MockManagementMockRecorder) GetBootCapabilities() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBootCapabilities", reflect.TypeOf((*MockManagement)(nil).GetBootCapabilities)) +} + // GetBootData mocks base method. func (m *MockManagement) GetBootData() (boot.BootSettingDataResponse, error) { m.ctrl.T.Helper() @@ -933,6 +948,20 @@ func (mr *MockManagementMockRecorder) SetLinkPreference(linkPreference, timeout return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLinkPreference", reflect.TypeOf((*MockManagement)(nil).SetLinkPreference), linkPreference, timeout) } +// SetRemoteEraseOptions mocks base method. +func (m *MockManagement) SetRemoteEraseOptions(eraseMask int, ssdPassword string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetRemoteEraseOptions", eraseMask, ssdPassword) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetRemoteEraseOptions indicates an expected call of SetRemoteEraseOptions. +func (mr *MockManagementMockRecorder) SetRemoteEraseOptions(eraseMask, ssdPassword any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRemoteEraseOptions", reflect.TypeOf((*MockManagement)(nil).SetRemoteEraseOptions), eraseMask, ssdPassword) +} + // UpdateWiFiSettings mocks base method. func (m *MockManagement) UpdateWiFiSettings(wifiEndpointSettings wifi.WiFiEndpointSettingsRequest, ieee8021xSettings models.IEEE8021xSettings, clientCredential, caCredential string) (wifiportconfiguration.Response, error) { m.ctrl.T.Helper() diff --git a/internal/mocks/wsv1_mocks.go b/internal/mocks/wsv1_mocks.go index fb7b47851..62213a34b 100644 --- a/internal/mocks/wsv1_mocks.go +++ b/internal/mocks/wsv1_mocks.go @@ -285,6 +285,21 @@ func (mr *MockFeatureMockRecorder) GetAuditLog(ctx, startIndex, guid any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuditLog", reflect.TypeOf((*MockFeature)(nil).GetAuditLog), ctx, startIndex, guid) } +// GetBootCapabilities mocks base method. +func (m *MockFeature) GetBootCapabilities(ctx context.Context, guid string) (dto.BootCapabilities, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBootCapabilities", ctx, guid) + ret0, _ := ret[0].(dto.BootCapabilities) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBootCapabilities indicates an expected call of GetBootCapabilities. +func (mr *MockFeatureMockRecorder) GetBootCapabilities(ctx, guid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBootCapabilities", reflect.TypeOf((*MockFeature)(nil).GetBootCapabilities), ctx, guid) +} + // GetBootSourceSetting mocks base method. func (m *MockFeature) GetBootSourceSetting(ctx context.Context, guid string) ([]dto.BootSources, error) { m.ctrl.T.Helper() @@ -541,6 +556,21 @@ func (mr *MockFeatureMockRecorder) GetPowerState(ctx, guid any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPowerState", reflect.TypeOf((*MockFeature)(nil).GetPowerState), ctx, guid) } +// GetRemoteEraseCapabilities mocks base method. +func (m *MockFeature) GetRemoteEraseCapabilities(ctx context.Context, guid string) (dto.BootCapabilities, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRemoteEraseCapabilities", ctx, guid) + ret0, _ := ret[0].(dto.BootCapabilities) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRemoteEraseCapabilities indicates an expected call of GetRemoteEraseCapabilities. +func (mr *MockFeatureMockRecorder) GetRemoteEraseCapabilities(ctx, guid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRemoteEraseCapabilities", reflect.TypeOf((*MockFeature)(nil).GetRemoteEraseCapabilities), ctx, guid) +} + // GetTLSSettingData mocks base method. func (m *MockFeature) GetTLSSettingData(c context.Context, guid string) ([]dto.SettingDataResponse, error) { m.ctrl.T.Helper() @@ -796,6 +826,20 @@ func (mr *MockFeatureMockRecorder) SetLinkPreference(c, guid, req any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLinkPreference", reflect.TypeOf((*MockFeature)(nil).SetLinkPreference), c, guid, req) } +// SetRemoteEraseOptions mocks base method. +func (m *MockFeature) SetRemoteEraseOptions(ctx context.Context, guid string, req dto.RemoteEraseRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetRemoteEraseOptions", ctx, guid, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetRemoteEraseOptions indicates an expected call of SetRemoteEraseOptions. +func (mr *MockFeatureMockRecorder) SetRemoteEraseOptions(ctx, guid, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRemoteEraseOptions", reflect.TypeOf((*MockFeature)(nil).SetRemoteEraseOptions), ctx, guid, req) +} + // SetWirelessProfileSync mocks base method. func (m *MockFeature) SetWirelessProfileSync(c context.Context, guid string, req dto.WirelessProfileSyncRequest) (dto.WirelessProfileSyncResponse, error) { m.ctrl.T.Helper() diff --git a/internal/usecase/devices/boot.go b/internal/usecase/devices/boot.go new file mode 100644 index 000000000..6ead6e6a1 --- /dev/null +++ b/internal/usecase/devices/boot.go @@ -0,0 +1,109 @@ +package devices + +import ( + "context" + "errors" + + "github.com/device-management-toolkit/console/internal/entity/dto/v1" + deviceManagement "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" + "github.com/device-management-toolkit/console/pkg/consoleerrors" +) + +func (uc *UseCase) GetRemoteEraseCapabilities(c context.Context, guid string) (dto.BootCapabilities, error) { + item, err := uc.repo.GetByID(c, guid, "") + if err != nil { + return dto.BootCapabilities{}, err + } + + if item == nil || item.GUID == "" { + return dto.BootCapabilities{}, ErrNotFound + } + + device, err := uc.device.SetupWsmanClient(c, *item, false, true) + if err != nil { + return dto.BootCapabilities{}, err + } + + capabilities, err := device.GetBootCapabilities() + if err != nil { + return dto.BootCapabilities{}, err + } + + uc.log.Debug("getRemoteEraseCapabilities: PlatformErase capability", "guid", guid, "PlatformErase", capabilities.PlatformErase, "supported", capabilities.PlatformErase != 0) + + return dto.BootCapabilities{ + SecureEraseAllSSDs: capabilities.PlatformErase&platformEraseSecureErase != 0, + TPMClear: capabilities.PlatformErase&platformEraseTPMClear != 0, + RestoreBIOSToEOM: capabilities.PlatformErase&platformEraseBIOSReload != 0, + UnconfigureCSME: capabilities.PlatformErase&platformEraseCSMEUnconfigure != 0, + }, nil +} + +func (uc *UseCase) SetRemoteEraseOptions(c context.Context, guid string, req dto.RemoteEraseRequest) error { + item, err := uc.repo.GetByID(c, guid, "") + if err != nil { + return err + } + + if item == nil || item.GUID == "" { + return ErrNotFound + } + + device, err := uc.device.SetupWsmanClient(c, *item, false, true) + if err != nil { + return err + } + + capabilities, err := device.GetBootCapabilities() + if err != nil { + return err + } + + if capabilities.PlatformErase == 0 { + return ValidationError{}.Wrap("SetRemoteEraseOptions", "check boot capabilities", "device does not support Remote Platform Erase") + } + + eraseMask := 0 + if req.SecureEraseAllSSDs { + eraseMask |= platformEraseSecureErase + } + + if req.TPMClear { + eraseMask |= platformEraseTPMClear + } + + if req.RestoreBIOSToEOM { + eraseMask |= platformEraseBIOSReload + } + + if req.UnconfigureCSME { + if capabilities.PlatformErase&platformEraseCSMEUnconfigure == 0 { + return ValidationError{}.Wrap("SetRemoteEraseOptions", "check boot capabilities", "device does not support CSME unconfigure") + } + + eraseMask |= platformEraseCSMEUnconfigure + } + + if eraseMask == 0 { + return ValidationError{}.Wrap("SetRemoteEraseOptions", "check erase options", "at least one erase option must be selected") + } + + uc.log.Debug( + "SetRemoteEraseOptions guid=%s eraseMask=0x%x secureErase=%v tpmClear=%v biosReload=%v csmeReset=%v", + guid, eraseMask, + req.SecureEraseAllSSDs, + req.TPMClear, + req.RestoreBIOSToEOM, + req.UnconfigureCSME, + ) + + if err := device.SetRemoteEraseOptions(eraseMask, req.SSDPassword); err != nil { + if errors.Is(err, deviceManagement.ErrRPENotEnabled) { + return NotSupportedError{Console: consoleerrors.CreateConsoleError("Remote Platform Erase is not enabled by the BIOS on this device")} + } + + return err + } + + return nil +} diff --git a/internal/usecase/devices/boot_test.go b/internal/usecase/devices/boot_test.go new file mode 100644 index 000000000..91f37d5c5 --- /dev/null +++ b/internal/usecase/devices/boot_test.go @@ -0,0 +1,462 @@ +package devices_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + gomock "go.uber.org/mock/gomock" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/boot" + + "github.com/device-management-toolkit/console/internal/entity" + "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/mocks" + devices "github.com/device-management-toolkit/console/internal/usecase/devices" + devicewsman "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" + "github.com/device-management-toolkit/console/pkg/consoleerrors" +) + +func TestGetRemoteEraseCapabilities(t *testing.T) { + t.Parallel() + + device := &entity.Device{ + GUID: "device-guid-123", + TenantID: "tenant-id-456", + } + + fullCapabilities := boot.BootCapabilitiesResponse{ + PlatformErase: 0x10000, // CSME bit only + } + + expectedDTO := dto.BootCapabilities{ + UnconfigureCSME: true, + } + + tests := []struct { + name string + manMock func(*mocks.MockWSMAN, *mocks.MockManagement) + repoMock func(*mocks.MockDeviceManagementRepository) + res dto.BootCapabilities + err error + }{ + { + name: "success", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(fullCapabilities, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: expectedDTO, + err: nil, + }, + { + name: "GetByID returns error", + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(nil, ErrGeneral) + }, + res: dto.BootCapabilities{}, + err: ErrGeneral, + }, + { + name: "GetByID returns nil device", + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(nil, nil) + }, + res: dto.BootCapabilities{}, + err: devices.ErrNotFound, + }, + { + name: "GetByID returns device with empty GUID", + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(&entity.Device{GUID: "", TenantID: "tenant-id-456"}, nil) + }, + res: dto.BootCapabilities{}, + err: devices.ErrNotFound, + }, + { + name: "SetupWsmanClient returns error", + manMock: func(man *mocks.MockWSMAN, _ *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(nil, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.BootCapabilities{}, + err: ErrGeneral, + }, + { + name: "GetBootCapabilities wsman call returns error", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{}, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.BootCapabilities{}, + err: ErrGeneral, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initInfoTest(t) + tc.manMock(wsmanMock, management) + tc.repoMock(repo) + + res, err := useCase.GetRemoteEraseCapabilities(context.Background(), device.GUID) + require.Equal(t, tc.err, err) + require.Equal(t, tc.res, res) + }) + } +} + +func TestSetRemoteEraseOptions(t *testing.T) { + t.Parallel() + + device := &entity.Device{ + GUID: "device-guid-123", + TenantID: "tenant-id-456", + } + + tests := []struct { + name string + req dto.RemoteEraseRequest + manMock func(*mocks.MockWSMAN, *mocks.MockManagement) + repoMock func(*mocks.MockDeviceManagementRepository) + err error + }{ + { + name: "no options selected returns ValidationError before reaching WSMAN", + req: dto.RemoteEraseRequest{}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: devices.ValidationError{}.Wrap("SetRemoteEraseOptions", "check erase options", "at least one erase option must be selected"), + }, + { + name: "success - specific supported eraseMask", + req: dto.RemoteEraseRequest{SecureEraseAllSSDs: true, TPMClear: true}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + man2.EXPECT(). + SetRemoteEraseOptions(0x44, ""). + Return(nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: nil, + }, + { + name: "success - RestoreBIOSToEOM sets platformEraseBIOSReload bit", + req: dto.RemoteEraseRequest{RestoreBIOSToEOM: true}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + man2.EXPECT(). + SetRemoteEraseOptions(0x4000000, ""). + Return(nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: nil, + }, + { + name: "device does not support RPE - PlatformErase is 0", + req: dto.RemoteEraseRequest{}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 0}, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: devices.ValidationError{}.Wrap("SetRemoteEraseOptions", "check boot capabilities", "device does not support Remote Platform Erase"), + }, + { + name: "eraseMask with PlatformErase nonzero succeeds regardless of specific bits", + req: dto.RemoteEraseRequest{SecureEraseAllSSDs: true}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + man2.EXPECT(). + SetRemoteEraseOptions(4, ""). + Return(nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: nil, + }, + { + name: "GetByID returns error", + req: dto.RemoteEraseRequest{}, + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(nil, ErrGeneral) + }, + err: ErrGeneral, + }, + { + name: "GetByID returns nil device", + req: dto.RemoteEraseRequest{}, + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(nil, nil) + }, + err: devices.ErrNotFound, + }, + { + name: "GetByID returns device with empty GUID", + req: dto.RemoteEraseRequest{}, + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(&entity.Device{GUID: "", TenantID: "tenant-id-456"}, nil) + }, + err: devices.ErrNotFound, + }, + { + name: "SetupWsmanClient returns error", + req: dto.RemoteEraseRequest{}, + manMock: func(man *mocks.MockWSMAN, _ *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(nil, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: ErrGeneral, + }, + { + name: "GetBootCapabilities returns error", + req: dto.RemoteEraseRequest{}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{}, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: ErrGeneral, + }, + { + name: "SetRemoteEraseOptions wsman call returns error", + req: dto.RemoteEraseRequest{SecureEraseAllSSDs: true}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + man2.EXPECT(). + SetRemoteEraseOptions(0x04, ""). // platformEraseSecureErase + Return(ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: ErrGeneral, + }, + { + name: "success - UnconfigureCSME only", + req: dto.RemoteEraseRequest{UnconfigureCSME: true}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 0x10001}, nil) // RPE + CSME bits + man2.EXPECT(). + SetRemoteEraseOptions(0x10000, ""). + Return(nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: nil, + }, + { + name: "UnconfigureCSME not supported by device", + req: dto.RemoteEraseRequest{UnconfigureCSME: true}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) // CSME bit (0x10000) not set + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: devices.ValidationError{}.Wrap("SetRemoteEraseOptions", "check boot capabilities", "device does not support CSME unconfigure"), + }, + { + name: "success - SSD password forwarded to wsman", + req: dto.RemoteEraseRequest{SecureEraseAllSSDs: true, SSDPassword: "s3cr3t"}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + man2.EXPECT(). + SetRemoteEraseOptions(4, "s3cr3t"). + Return(nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: nil, + }, + { + name: "no erase options selected returns ValidationError", + req: dto.RemoteEraseRequest{}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: devices.ValidationError{}.Wrap("SetRemoteEraseOptions", "check erase options", "at least one erase option must be selected"), + }, + { + name: "SetRemoteEraseOptions returns ErrRPENotEnabled - converted to NotSupportedError", + req: dto.RemoteEraseRequest{SecureEraseAllSSDs: true}, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetBootCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + man2.EXPECT(). + SetRemoteEraseOptions(0x04, ""). + Return(devicewsman.ErrRPENotEnabled) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: devices.NotSupportedError{Console: consoleerrors.CreateConsoleError("Remote Platform Erase is not enabled by the BIOS on this device")}, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initInfoTest(t) + tc.manMock(wsmanMock, management) + tc.repoMock(repo) + + err := useCase.SetRemoteEraseOptions(context.Background(), device.GUID, tc.req) + require.Equal(t, tc.err, err) + }) + } +} diff --git a/internal/usecase/devices/features.go b/internal/usecase/devices/features.go index 2bc7bcfde..fea875473 100644 --- a/internal/usecase/devices/features.go +++ b/internal/usecase/devices/features.go @@ -20,12 +20,12 @@ import ( var ErrOCRNotSupportedUseCase = NotSupportedError{Console: consoleerrors.CreateConsoleError("One Click Recovery Unsupported")} -// AMT BootService EnabledState constants. +// AMT BootService EnabledState constants (CIM_BootService.RequestStateChange ValueMap). const ( - // EnabledState values. - enabledStateEnabled = 32769 - enabledStateEnabledButOffline = 32771 - enabledStateDisabled = 32768 + enabledStateOCRAndRPEDisabled = 32768 // OCR disabled, RPE disabled + enabledStateOCREnabled = 32769 // OCR enabled, RPE disabled + enabledStateRPEEnabled = 32770 // OCR disabled, RPE enabled + enabledStateOCRAndRPEEnabled = 32771 // OCR enabled, RPE enabled ) // User consent option string values. @@ -38,7 +38,16 @@ const ( targetsPBAWinREInstanceID = "Intel(r) AMT: Force OCR UEFI Boot Option" ) -type OCRData struct { +// PlatformErase capability bitmask bits (AMT_BootCapabilities.PlatformErase). +const ( + platformEraseRPESupport = 0x01 // Bit 0: RPE overall support + platformEraseSecureErase = 0x04 // Bit 2: Secure Erase All SSDs + platformEraseTPMClear = 0x40 // Bit 6: TPM Clear + platformEraseCSMEUnconfigure = 0x10000 // Bit 16: CSME Unconfigure (UI sentinel) + platformEraseBIOSReload = 0x4000000 // Bit 26: BIOS Reload of Golden Configuration +) + +type BootConfiguration struct { bootService cimBoot.BootService bootSourceSettings []cimBoot.BootSourceSetting capabilities boot.BootCapabilitiesResponse @@ -92,46 +101,58 @@ func (uc *UseCase) GetFeatures(c context.Context, guid string) (settingsResults settingsResults.KVMAvailable = settingsResultsV2.KVMAvailable // Get boot service related settings - err = getOneClickRecoverySettings(&settingsResultsV2, device) - if err != nil { - return dto.Features{}, dtov2.Features{}, err - } + getBootConfigurationSettings(&settingsResultsV2, device) settingsResults.OCR = settingsResultsV2.OCR + settingsResults.RPE = settingsResultsV2.RPE settingsResults.HTTPSBootSupported = settingsResultsV2.HTTPSBootSupported settingsResults.WinREBootSupported = settingsResultsV2.WinREBootSupported settingsResults.LocalPBABootSupported = settingsResultsV2.LocalPBABootSupported + settingsResults.RPESupported = settingsResultsV2.RPESupported + + uc.log.Debug("GetFeatures: RemoteErase (PlatformErase) support guid=%s RPESupported=%v RPE=%v", guid, settingsResultsV2.RPESupported, settingsResultsV2.RPE) return settingsResults, settingsResultsV2, nil } -func getOCRData(device wsman.Management) (OCRData, error) { - bootService, err := device.GetBootService() - if err != nil { - return OCRData{}, err - } +func getBootConfiguration(device wsman.Management) BootConfiguration { + // These are non-fatal: if unavailable, OCR/RPE state and boot settings default to zero values + bootService, _ := device.GetBootService() - bootSourceSettings, err := device.GetCIMBootSourceSetting() - if err != nil { - return OCRData{}, err - } + var bootSourceSettings cimBoot.Response - capabilities, err := device.GetPowerCapabilities() - if err != nil { - return OCRData{}, err - } + bootSourceSettings, _ = device.GetCIMBootSourceSetting() - bootData, err := device.GetBootData() - if err != nil { - return OCRData{}, err - } + // Non-fatal: older devices that do not support OCR/RPE will return an error here; + // all capability-derived fields will default to false/zero. + capabilities, _ := device.GetPowerCapabilities() + + bootData, _ := device.GetBootData() - return OCRData{ + return BootConfiguration{ bootService: bootService, bootSourceSettings: bootSourceSettings.Body.PullResponse.BootSourceSettingItems, capabilities: capabilities, bootData: bootData, - }, nil + } +} + +func getBootConfigurationSettings(settingsResultsV2 *dtov2.Features, device wsman.Management) { + bootConfig := getBootConfiguration(device) + + isOCR := bootConfig.bootService.EnabledState == enabledStateOCREnabled || bootConfig.bootService.EnabledState == enabledStateOCRAndRPEEnabled + isRPE := bootConfig.bootService.EnabledState == enabledStateRPEEnabled || bootConfig.bootService.EnabledState == enabledStateOCRAndRPEEnabled + + result := FindBootSettingInstances(bootConfig.bootSourceSettings) + + // AMT_BootSettingData.UEFIHTTPSBootEnabled is read-only. AMT_BootCapabilities instance is read-only. + // So, these cannot be updated + settingsResultsV2.OCR = isOCR + settingsResultsV2.RPE = isRPE + settingsResultsV2.HTTPSBootSupported = result.IsHTTPSBootExists && bootConfig.capabilities.ForceUEFIHTTPSBoot && bootConfig.bootData.UEFIHTTPSBootEnabled + settingsResultsV2.WinREBootSupported = result.IsWinREExists && bootConfig.bootData.WinREBootEnabled && bootConfig.capabilities.ForceWinREBoot + settingsResultsV2.LocalPBABootSupported = result.IsPBAExists && bootConfig.bootData.UEFILocalPBABootEnabled && bootConfig.capabilities.ForceUEFILocalPBABoot + settingsResultsV2.RPESupported = bootConfig.capabilities.PlatformErase&platformEraseRPESupport != 0 } func FindBootSettingInstances(bootSourceSettings []cimBoot.BootSourceSetting) dtov2.BootSettings { @@ -161,32 +182,6 @@ func FindBootSettingInstances(bootSourceSettings []cimBoot.BootSourceSetting) dt return result } -func getOneClickRecoverySettings(settingsResultsV2 *dtov2.Features, device wsman.Management) error { - ocrData, err := getOCRData(device) - if err != nil { - return err - } - - isOCR := ocrData.bootService.EnabledState == enabledStateEnabled || ocrData.bootService.EnabledState == enabledStateEnabledButOffline - - result := FindBootSettingInstances(ocrData.bootSourceSettings) - - // AMT_BootSettingData.UEFIHTTPSBootEnabled is read-only. AMT_BootCapabilities instance is read-only. - // So, these cannot be updated - isHTTPSBootSupported := result.IsHTTPSBootExists && ocrData.capabilities.ForceUEFIHTTPSBoot && ocrData.bootData.UEFIHTTPSBootEnabled - - isWinREBootSupported := result.IsWinREExists && ocrData.bootData.WinREBootEnabled && ocrData.capabilities.ForceWinREBoot - - isLocalPBABootSupported := result.IsPBAExists && ocrData.bootData.UEFILocalPBABootEnabled && ocrData.capabilities.ForceUEFILocalPBABoot - - settingsResultsV2.OCR = isOCR - settingsResultsV2.HTTPSBootSupported = isHTTPSBootSupported - settingsResultsV2.WinREBootSupported = isWinREBootSupported - settingsResultsV2.LocalPBABootSupported = isLocalPBABootSupported - - return nil -} - func (uc *UseCase) SetFeatures(c context.Context, guid string, features dto.Features) (settingsResults dto.Features, settingsResultsV2 dtov2.Features, err error) { item, err := uc.repo.GetByID(c, guid, "") if err != nil { @@ -241,22 +236,22 @@ func (uc *UseCase) SetFeatures(c context.Context, guid string, features dto.Feat settingsResults.UserConsent = features.UserConsent settingsResultsV2.UserConsent = features.UserConsent - // Configure OCR settings - requestedState := 0 - if features.OCR { - requestedState = enabledStateEnabled - } else { - requestedState = enabledStateDisabled + // RPE: must run before BootServiceStateChange (OCR state change blocks the PUT) + if err := setRPE(features.RPE, &settingsResultsV2, device); err != nil { + return settingsResults, settingsResultsV2, err } + // Remote Platform Erase (RPE) support and capabilities may be affected by the state change, so we need to get the settings again to return the correct values. + syncRPEResults(&settingsResultsV2, &settingsResults) + + // Configure OCR/RPE boot service state + // 32768 = both disabled, 32769 = OCR only, 32770 = RPE only, 32771 = both enabled + requestedState := ocrBootState(features.OCR, features.RPE) + _, err = device.BootServiceStateChange(requestedState) if err == nil { // Get OCR settings - err = getOneClickRecoverySettings(&settingsResultsV2, device) - if err != nil { - return dto.Features{}, dtov2.Features{}, err - } - + getBootConfigurationSettings(&settingsResultsV2, device) settingsResults.OCR = settingsResultsV2.OCR settingsResults.HTTPSBootSupported = settingsResultsV2.HTTPSBootSupported settingsResults.WinREBootSupported = settingsResultsV2.WinREBootSupported @@ -268,6 +263,36 @@ func (uc *UseCase) SetFeatures(c context.Context, guid string, features dto.Feat return settingsResults, settingsResultsV2, nil } +func syncRPEResults(src *dtov2.Features, dst *dto.Features) { + dst.RPE = src.RPE + dst.RPESupported = src.RPESupported +} + +func setRPE(enableRemoteErase bool, settingsResultsV2 *dtov2.Features, device wsman.Management) error { + bootCapabilities, err := device.GetPowerCapabilities() + if err != nil { + return err + } + + settingsResultsV2.RPE = enableRemoteErase + settingsResultsV2.RPESupported = bootCapabilities.PlatformErase&platformEraseRPESupport != 0 // Bit 0: RPE overall support + + return nil +} + +func ocrBootState(ocr, rpe bool) int { + switch { + case ocr && rpe: + return enabledStateOCRAndRPEEnabled + case ocr: + return enabledStateOCREnabled + case rpe: + return enabledStateRPEEnabled + default: + return enabledStateOCRAndRPEDisabled + } +} + func handleAMTKVMError(err error, results *dtov2.Features) bool { amtErr := &amterror.AMTError{} if errors.As(err, &amtErr) { diff --git a/internal/usecase/devices/features_private_test.go b/internal/usecase/devices/features_private_test.go new file mode 100644 index 000000000..fb1a0340b --- /dev/null +++ b/internal/usecase/devices/features_private_test.go @@ -0,0 +1,89 @@ +package devices + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/console/internal/entity/dto/v1" + dtov2 "github.com/device-management-toolkit/console/internal/entity/dto/v2" +) + +func TestOcrBootState(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ocr bool + rpe bool + expected int + }{ + { + name: "both disabled", + ocr: false, + rpe: false, + expected: enabledStateOCRAndRPEDisabled, + }, + { + name: "OCR only", + ocr: true, + rpe: false, + expected: enabledStateOCREnabled, + }, + { + name: "RPE only", + ocr: false, + rpe: true, + expected: enabledStateRPEEnabled, + }, + { + name: "both enabled", + ocr: true, + rpe: true, + expected: enabledStateOCRAndRPEEnabled, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := ocrBootState(tc.ocr, tc.rpe) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestSyncRPEResults(t *testing.T) { + t.Parallel() + + src := &dtov2.Features{ + RPE: true, + RPESupported: true, + } + + dst := &dto.Features{} + + syncRPEResults(src, dst) + + require.Equal(t, src.RPE, dst.RPE) + require.Equal(t, src.RPESupported, dst.RPESupported) +} + +func TestSyncRPEResultsZeroValues(t *testing.T) { + t.Parallel() + + src := &dtov2.Features{} + + dst := &dto.Features{ + RPE: true, + RPESupported: true, + } + + syncRPEResults(src, dst) + + require.False(t, dst.RPE) + require.False(t, dst.RPESupported) +} diff --git a/internal/usecase/devices/features_test.go b/internal/usecase/devices/features_test.go index 93f9c7584..54779ed5a 100644 --- a/internal/usecase/devices/features_test.go +++ b/internal/usecase/devices/features_test.go @@ -316,7 +316,7 @@ func TestGetFeatures(t *testing.T) { HTTPSBootSupported: true, WinREBootSupported: false, LocalPBABootSupported: false, - RemoteErase: false, + RPE: false, }, resV2: dtov2.Features{ UserConsent: "kvm", @@ -570,18 +570,27 @@ func TestGetFeatures(t *testing.T) { }, }, }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) man2.EXPECT(). GetBootService(). Return(cimBoot.BootService{}, ErrGeneral) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: ErrGeneral, + res: featureSetNoOCR, + resV2: featureSetV2NoOCR, + err: nil, }, { name: "GetFeatures fails on boot service", @@ -619,18 +628,27 @@ func TestGetFeatures(t *testing.T) { }, }, }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) man2.EXPECT(). GetBootService(). Return(cimBoot.BootService{}, ErrGeneral) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: ErrGeneral, + res: featureSetNoOCR, + resV2: featureSetV2NoOCR, + err: nil, }, { name: "GetFeatures fails on boot source setting", @@ -668,6 +686,9 @@ func TestGetFeatures(t *testing.T) { }, }, }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) man2.EXPECT(). GetBootService(). Return(cimBoot.BootService{ @@ -676,18 +697,42 @@ func TestGetFeatures(t *testing.T) { man2.EXPECT(). GetCIMBootSourceSetting(). Return(cimBoot.Response{}, ErrGeneral) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: ErrGeneral, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, // GetBootService succeeded with EnabledState=32769 + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, + }, + err: nil, }, { - name: "GetFeatures fails on power capabilities", + name: "GetFeatures succeeds with zero capabilities when boot capabilities unavailable", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -746,15 +791,36 @@ func TestGetFeatures(t *testing.T) { man2.EXPECT(). GetPowerCapabilities(). Return(boot.BootCapabilitiesResponse{}, ErrGeneral) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: ErrGeneral, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, // GetBootService succeeded with EnabledState=32769 + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, + }, + err: nil, }, { name: "GetFeatures fails on boot data", @@ -829,9 +895,27 @@ func TestGetFeatures(t *testing.T) { GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: ErrGeneral, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, // GetBootService succeeded with EnabledState=32769 + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, + }, + err: nil, }, { name: "GetFeatures on ISM", @@ -986,7 +1070,8 @@ func TestGetFeatures(t *testing.T) { Redirection: true, KVMAvailable: true, OptInState: 1, - OCR: true, // Should be true for EnabledState 32771 + OCR: true, // EnabledState 32771 = OCR+RPE both enabled + RPE: true, // EnabledState 32771 = OCR+RPE both enabled HTTPSBootSupported: true, WinREBootSupported: false, LocalPBABootSupported: false, @@ -1000,6 +1085,7 @@ func TestGetFeatures(t *testing.T) { KVMAvailable: true, OptInState: 1, OCR: true, + RPE: true, HTTPSBootSupported: true, WinREBootSupported: false, LocalPBABootSupported: false, @@ -1142,157 +1228,35 @@ func TestGetFeatures(t *testing.T) { }, }, }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) man2.EXPECT(). GetBootService(). Return(cimBoot.BootService{}, ErrGeneral) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: ErrGeneral, - }, - } - - for _, tc := range tests { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - useCase, wsmanMock, management, repo := initInfoTest(t) - - tc.manMock(wsmanMock, management) - - tc.repoMock(repo) - - v1, v2, err := useCase.GetFeatures(context.Background(), device.GUID) - - require.Equal(t, tc.res, v1) - - require.Equal(t, tc.resV2, v2) - - require.IsType(t, tc.err, err) - }) - } -} - -func TestSetFeatures(t *testing.T) { - t.Parallel() - - device := &entity.Device{ - GUID: "device-guid-123", - TenantID: "tenant-id-456", - } - - featureSet := dto.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - OCR: true, - } - - featureSetDisabledOCR := dto.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - Redirection: true, - OCR: false, - } - - featureSetV2 := dtov2.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - Redirection: true, - KVMAvailable: true, - OCR: true, - HTTPSBootSupported: true, - WinREBootSupported: false, - LocalPBABootSupported: false, - RemoteErase: false, - } - - featureSetDisabledOCRResult := dto.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - KVMAvailable: true, - Redirection: true, - OCR: false, - HTTPSBootSupported: true, - WinREBootSupported: false, - LocalPBABootSupported: false, - RemoteErase: false, - } - - featureSetV2DisabledOCR := dtov2.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - Redirection: true, - KVMAvailable: true, - OCR: false, - HTTPSBootSupported: true, - WinREBootSupported: false, - LocalPBABootSupported: false, - RemoteErase: false, - } - - failGetByIDResult := dto.Features{} - - tests := []test{ - { - name: "Device not found - nil device", - action: 0, - manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, - repoMock: func(repo *mocks.MockDeviceManagementRepository) { - repo.EXPECT(). - GetByID(context.Background(), device.GUID, ""). - Return(nil, nil) // Returns nil device, no error - }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: devices.ErrNotFound, - }, - { - name: "Device not found - empty GUID", - action: 0, - manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, - repoMock: func(repo *mocks.MockDeviceManagementRepository) { - emptyDevice := &entity.Device{ - GUID: "", // Empty GUID - TenantID: "tenant-id-456", - } - repo.EXPECT(). - GetByID(context.Background(), device.GUID, ""). - Return(emptyDevice, nil) - }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: devices.ErrNotFound, + res: featureSetNoOCR, + resV2: featureSetV2NoOCR, + err: nil, }, { - name: "success", + name: "GetFeatures with RPE only enabled (state 32770)", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). Return(man2, nil) - man2.EXPECT(). - RequestAMTRedirectionServiceStateChange(true, true). - Return(redirection.EnableIDERAndSOL, 1, nil) - man2.EXPECT(). - SetKVMRedirection(true). - Return(1, nil) man2.EXPECT(). GetAMTRedirectionService(). Return(redirection.Response{ @@ -1303,38 +1267,718 @@ func TestSetFeatures(t *testing.T) { }, }, }, nil) - man2.EXPECT(). - SetAMTRedirectionService(&redirection.RedirectionRequest{ - EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), - ListenerEnabled: true, - }). - Return(redirection.Response{ - Body: redirection.Body{ - GetAndPutResponse: redirection.RedirectionResponse{ - EnabledState: 32771, - ListenerEnabled: true, - }, - }, - }, nil) man2.EXPECT(). GetIPSOptInService(). Return(optin.Response{ Body: optin.Body{ GetAndPutResponse: optin.OptInServiceResponse{ OptInRequired: 1, - OptInState: 0, + OptInState: 1, }, }, }, nil) man2.EXPECT(). - SetIPSOptInService(optin.OptInServiceRequest{ - OptInRequired: 1, - OptInState: 0, - }). - Return(nil) - man2.EXPECT(). - BootServiceStateChange(32769). // OCR enabled - Return(cimBoot.BootService{}, nil) + GetKVMRedirection(). + Return(kvm.Response{ + Body: kvm.Body{ + GetResponse: kvm.KVMRedirectionSAP{ + EnabledState: kvm.EnabledState(redirection.Enabled), + }, + }, + }, nil) + man2.EXPECT(). + GetBootService(). + Return(cimBoot.BootService{EnabledState: 32770}, nil) // RPE only + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{}, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{ + PlatformErase: 0x45, // bits 0,2,6 set: RPESupport | SecureErase | TPMClear + }, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: false, + RPE: true, + RPESupported: true, + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: false, + RPE: true, + RPESupported: true, + }, + err: nil, + }, + { + name: "GetFeatures with full RPE capability bitmask", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 1, + }, + }, + }, nil) + man2.EXPECT(). + GetKVMRedirection(). + Return(kvm.Response{ + Body: kvm.Body{ + GetResponse: kvm.KVMRedirectionSAP{ + EnabledState: kvm.EnabledState(redirection.Enabled), + }, + }, + }, nil) + man2.EXPECT(). + GetBootService(). + Return(cimBoot.BootService{EnabledState: 32771}, nil) // OCR+RPE + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{}, nil) + man2.EXPECT(). + GetPowerCapabilities(). + // bits 0,2,6,25,26 set: RPESupport|SecureErase|TPMClear|ClearBIOSNVM|BIOSReload + Return(boot.BootCapabilitiesResponse{PlatformErase: 0x6000045}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, + RPE: true, + RPESupported: true, + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, + RPE: true, + RPESupported: true, + }, + err: nil, + }, + { + name: "success with local PBA boot supported", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: redirection.IDERAndSOLAreEnabled, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 1, + }, + }, + }, nil) + man2.EXPECT(). + GetKVMRedirection(). + Return(kvm.Response{ + Body: kvm.Body{ + GetResponse: kvm.KVMRedirectionSAP{ + EnabledState: kvm.EnabledState(redirection.Enabled), + }, + }, + }, nil) + man2.EXPECT(). + GetBootService(). + Return(cimBoot.BootService{EnabledState: 32769}, nil) // OCR enabled + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{ + Body: cimBoot.Body{ + PullResponse: cimBoot.PullResponse{ + BootSourceSettingItems: []cimBoot.BootSourceSetting{ + { + InstanceID: "Intel(r) AMT: Force OCR UEFI Boot Option", + BIOSBootString: "Local PBA Boot", + }, + }, + }, + }, + }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{ + ForceUEFILocalPBABoot: true, + }, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{ + UEFILocalPBABootEnabled: true, + }, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, + LocalPBABootSupported: true, + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OptInState: 1, + OCR: true, + LocalPBABootSupported: true, + }, + err: nil, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initInfoTest(t) + + tc.manMock(wsmanMock, management) + + tc.repoMock(repo) + + v1, v2, err := useCase.GetFeatures(context.Background(), device.GUID) + + require.Equal(t, tc.res, v1) + + require.Equal(t, tc.resV2, v2) + + require.IsType(t, tc.err, err) + }) + } +} + +func TestSetFeatures(t *testing.T) { + t.Parallel() + + device := &entity.Device{ + GUID: "device-guid-123", + TenantID: "tenant-id-456", + } + + featureSet := dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + OCR: true, + } + + featureSetDisabledOCR := dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + OCR: false, + } + + featureSetV2 := dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OCR: true, + HTTPSBootSupported: true, + WinREBootSupported: false, + LocalPBABootSupported: false, + RPE: false, + } + + featureSetDisabledOCRResult := dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + KVMAvailable: true, + Redirection: true, + OCR: false, + HTTPSBootSupported: true, + WinREBootSupported: false, + LocalPBABootSupported: false, + RPE: false, + } + + featureSetV2DisabledOCR := dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OCR: false, + HTTPSBootSupported: true, + WinREBootSupported: false, + LocalPBABootSupported: false, + RPE: false, + } + + featureSetWithRPE := dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + OCR: true, + RPE: true, + } + + featureSetRPEOnly := dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + OCR: false, + RPE: true, + } + + failGetByIDResult := dto.Features{} + + tests := []test{ + { + name: "Device not found - nil device", + action: 0, + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(nil, nil) // Returns nil device, no error + }, + res: dto.Features{}, + resV2: dtov2.Features{}, + err: devices.ErrNotFound, + }, + { + name: "Device not found - empty GUID", + action: 0, + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + emptyDevice := &entity.Device{ + GUID: "", // Empty GUID + TenantID: "tenant-id-456", + } + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(emptyDevice, nil) + }, + res: dto.Features{}, + resV2: dtov2.Features{}, + err: devices.ErrNotFound, + }, + { + name: "success", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + RequestAMTRedirectionServiceStateChange(true, true). + Return(redirection.EnableIDERAndSOL, 1, nil) + man2.EXPECT(). + SetKVMRedirection(true). + Return(1, nil) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + SetAMTRedirectionService(&redirection.RedirectionRequest{ + EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), + ListenerEnabled: true, + }). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 0, + }, + }, + }, nil) + man2.EXPECT(). + SetIPSOptInService(optin.OptInServiceRequest{ + OptInRequired: 1, + OptInState: 0, + }). + Return(nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) + man2.EXPECT(). + BootServiceStateChange(32769). // OCR enabled + Return(cimBoot.BootService{}, nil) + man2.EXPECT(). + GetBootService(). + Return(cimBoot.BootService{ + EnabledState: 32769, + }, nil) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{ + Body: cimBoot.Body{ + PullResponse: cimBoot.PullResponse{ + BootSourceSettingItems: []cimBoot.BootSourceSetting{ + { + InstanceID: "Intel(r) AMT: Force OCR UEFI HTTPS Boot", + }, + { + InstanceID: "Intel(r) AMT: Force OCR UEFI Boot Option", + }, + }, + }, + }, + }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{ + ForceUEFIHTTPSBoot: true, + ForceWinREBoot: false, + ForceUEFILocalPBABoot: false, + }, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{ + UEFIHTTPSBootEnabled: true, + WinREBootEnabled: false, + UEFILocalPBABootEnabled: false, + }, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + KVMAvailable: true, + Redirection: true, + OCR: true, + HTTPSBootSupported: true, + WinREBootSupported: false, + LocalPBABootSupported: false, + RPE: false, + }, + resV2: featureSetV2, + err: nil, + }, + { + name: "success with OCR disabled", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + RequestAMTRedirectionServiceStateChange(true, true). + Return(redirection.EnableIDERAndSOL, 1, nil) + man2.EXPECT(). + SetKVMRedirection(true). + Return(1, nil) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + SetAMTRedirectionService(&redirection.RedirectionRequest{ + EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), + ListenerEnabled: true, + }). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 0, + }, + }, + }, nil) + man2.EXPECT(). + SetIPSOptInService(optin.OptInServiceRequest{ + OptInRequired: 1, + OptInState: 0, + }). + Return(nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) + man2.EXPECT(). + BootServiceStateChange(32768). + Return(cimBoot.BootService{}, nil) + man2.EXPECT(). + GetBootService(). + Return(cimBoot.BootService{ + EnabledState: 32768, + }, nil) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{ + Body: cimBoot.Body{ + PullResponse: cimBoot.PullResponse{ + BootSourceSettingItems: []cimBoot.BootSourceSetting{ + { + InstanceID: "Intel(r) AMT: Force OCR UEFI HTTPS Boot", + }, + { + InstanceID: "Intel(r) AMT: Force OCR UEFI Boot Option", + }, + }, + }, + }, + }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{ + ForceUEFIHTTPSBoot: true, + ForceWinREBoot: false, + ForceUEFILocalPBABoot: false, + }, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{ + UEFIHTTPSBootEnabled: true, + WinREBootEnabled: false, + UEFILocalPBABootEnabled: false, + }, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: featureSetDisabledOCRResult, + resV2: featureSetV2DisabledOCR, + err: nil, + }, + { + name: "GetById fails", + action: 0, + manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(nil, ErrGeneral) + }, + res: failGetByIDResult, + resV2: dtov2.Features{}, + err: devices.ErrGeneral, + }, + { + name: "SetFeatures fails on redirection service", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + RequestAMTRedirectionServiceStateChange(true, true). + Return(redirection.RequestedState(0), 0, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: failGetByIDResult, + resV2: dtov2.Features{}, + err: ErrGeneral, + }, + { + name: "SetFeatures fails on KVM with non-AMT error", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + RequestAMTRedirectionServiceStateChange(true, true). + Return(redirection.EnableIDERAndSOL, 1, nil) + man2.EXPECT(). + SetKVMRedirection(true). + Return(0, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + err: ErrGeneral, + }, + { + name: "SetFeatures fails on KVM with AMT error", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + RequestAMTRedirectionServiceStateChange(true, true). + Return(redirection.EnableIDERAndSOL, 1, nil) + man2.EXPECT(). + SetKVMRedirection(true). + Return(0, amterror.DecodeAMTErrorString(DestinationUnreachable)) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + SetAMTRedirectionService(&redirection.RedirectionRequest{ + EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), + ListenerEnabled: true, + }). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 0, + }, + }, + }, nil) + man2.EXPECT(). + SetIPSOptInService(optin.OptInServiceRequest{ + OptInRequired: 1, + OptInState: 0, + }). + Return(nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) + man2.EXPECT(). + BootServiceStateChange(32769). + Return(cimBoot.BootService{}, nil) man2.EXPECT(). GetBootService(). Return(cimBoot.BootService{ @@ -1380,20 +2024,125 @@ func TestSetFeatures(t *testing.T) { UserConsent: "kvm", EnableSOL: true, EnableIDER: true, - EnableKVM: true, - KVMAvailable: true, + EnableKVM: false, Redirection: true, OCR: true, HTTPSBootSupported: true, WinREBootSupported: false, LocalPBABootSupported: false, - RemoteErase: false, + RPE: false, }, - resV2: featureSetV2, - err: nil, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: false, + Redirection: true, + KVMAvailable: false, + OCR: true, + HTTPSBootSupported: true, + WinREBootSupported: false, + LocalPBABootSupported: false, + }, + err: nil, }, { - name: "success with OCR disabled", + name: "SetFeatures fails on redirection service set", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + RequestAMTRedirectionServiceStateChange(true, true). + Return(redirection.EnableIDERAndSOL, 1, nil) + man2.EXPECT(). + SetKVMRedirection(true). + Return(1, nil) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{}, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.Features{ + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + }, + resV2: dtov2.Features{ + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + KVMAvailable: true, + }, + err: ErrGeneral, + }, + { + name: "SetFeatures fails on user consent", + action: 0, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT(). + SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). + Return(man2, nil) + man2.EXPECT(). + RequestAMTRedirectionServiceStateChange(true, true). + Return(redirection.EnableIDERAndSOL, 1, nil) + man2.EXPECT(). + SetKVMRedirection(true). + Return(1, nil) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + SetAMTRedirectionService(&redirection.RedirectionRequest{ + EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), + ListenerEnabled: true, + }). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{}, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT(). + GetByID(context.Background(), device.GUID, ""). + Return(device, nil) + }, + res: dto.Features{ + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + }, + resV2: dtov2.Features{ + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + }, + err: ErrGeneral, + }, + { + name: "SetFeatures fails on boot service state change", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1444,69 +2193,38 @@ func TestSetFeatures(t *testing.T) { OptInState: 0, }). Return(nil) - man2.EXPECT(). - BootServiceStateChange(32768). - Return(cimBoot.BootService{}, nil) - man2.EXPECT(). - GetBootService(). - Return(cimBoot.BootService{ - EnabledState: 32768, - }, nil) - man2.EXPECT(). - GetCIMBootSourceSetting(). - Return(cimBoot.Response{ - Body: cimBoot.Body{ - PullResponse: cimBoot.PullResponse{ - BootSourceSettingItems: []cimBoot.BootSourceSetting{ - { - InstanceID: "Intel(r) AMT: Force OCR UEFI HTTPS Boot", - }, - { - InstanceID: "Intel(r) AMT: Force OCR UEFI Boot Option", - }, - }, - }, - }, - }, nil) man2.EXPECT(). GetPowerCapabilities(). - Return(boot.BootCapabilitiesResponse{ - ForceUEFIHTTPSBoot: true, - ForceWinREBoot: false, - ForceUEFILocalPBABoot: false, - }, nil) + Return(boot.BootCapabilitiesResponse{}, nil) man2.EXPECT(). - GetBootData(). - Return(boot.BootSettingDataResponse{ - UEFIHTTPSBootEnabled: true, - WinREBootEnabled: false, - UEFILocalPBABootEnabled: false, - }, nil) + BootServiceStateChange(32769). + Return(cimBoot.BootService{}, ErrGeneral) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: featureSetDisabledOCRResult, - resV2: featureSetV2DisabledOCR, - err: nil, - }, - { - name: "GetById fails", - action: 0, - manMock: func(_ *mocks.MockWSMAN, _ *mocks.MockManagement) {}, - repoMock: func(repo *mocks.MockDeviceManagementRepository) { - repo.EXPECT(). - GetByID(context.Background(), device.GUID, ""). - Return(nil, ErrGeneral) + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + KVMAvailable: true, + Redirection: true, }, - res: failGetByIDResult, - resV2: dtov2.Features{}, - err: devices.ErrGeneral, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + }, + err: nil, }, { - name: "SetFeatures fails on redirection service", + name: "SetFeatures fails on OCR settings retrieval after successful state change", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1514,40 +2232,93 @@ func TestSetFeatures(t *testing.T) { Return(man2, nil) man2.EXPECT(). RequestAMTRedirectionServiceStateChange(true, true). - Return(redirection.RequestedState(0), 0, ErrGeneral) - }, - repoMock: func(repo *mocks.MockDeviceManagementRepository) { - repo.EXPECT(). - GetByID(context.Background(), device.GUID, ""). - Return(device, nil) - }, - res: failGetByIDResult, - resV2: dtov2.Features{}, - err: ErrGeneral, - }, - { - name: "SetFeatures fails on KVM with non-AMT error", - action: 0, - manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { - man.EXPECT(). - SetupWsmanClient(gomock.Any(), gomock.Any(), false, true). - Return(man2, nil) + Return(redirection.EnableIDERAndSOL, 1, nil) + man2.EXPECT(). + SetKVMRedirection(true). + Return(1, nil) + man2.EXPECT(). + GetAMTRedirectionService(). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + SetAMTRedirectionService(&redirection.RedirectionRequest{ + EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), + ListenerEnabled: true, + }). + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 0, + }, + }, + }, nil) + man2.EXPECT(). + SetIPSOptInService(optin.OptInServiceRequest{ + OptInRequired: 1, + OptInState: 0, + }). + Return(nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) + man2.EXPECT(). + BootServiceStateChange(32769). + Return(cimBoot.BootService{}, nil) man2.EXPECT(). - RequestAMTRedirectionServiceStateChange(true, true). - Return(redirection.EnableIDERAndSOL, 1, nil) + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) man2.EXPECT(). - SetKVMRedirection(true). - Return(0, ErrGeneral) + GetBootService(). + Return(cimBoot.BootService{}, ErrGeneral) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - err: ErrGeneral, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + }, + err: nil, }, { - name: "SetFeatures fails on KVM with AMT error", + name: "SetFeatures returns nil error when getBootConfigurationSettings fails after successful BootServiceStateChange", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1558,7 +2329,7 @@ func TestSetFeatures(t *testing.T) { Return(redirection.EnableIDERAndSOL, 1, nil) man2.EXPECT(). SetKVMRedirection(true). - Return(0, amterror.DecodeAMTErrorString(DestinationUnreachable)) + Return(1, nil) man2.EXPECT(). GetAMTRedirectionService(). Return(redirection.Response{ @@ -1598,44 +2369,24 @@ func TestSetFeatures(t *testing.T) { OptInState: 0, }). Return(nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, nil) man2.EXPECT(). BootServiceStateChange(32769). Return(cimBoot.BootService{}, nil) man2.EXPECT(). GetBootService(). - Return(cimBoot.BootService{ - EnabledState: 32769, - }, nil) + Return(cimBoot.BootService{}, nil) man2.EXPECT(). GetCIMBootSourceSetting(). - Return(cimBoot.Response{ - Body: cimBoot.Body{ - PullResponse: cimBoot.PullResponse{ - BootSourceSettingItems: []cimBoot.BootSourceSetting{ - { - InstanceID: "Intel(r) AMT: Force OCR UEFI HTTPS Boot", - }, - { - InstanceID: "Intel(r) AMT: Force OCR UEFI Boot Option", - }, - }, - }, - }, - }, nil) + Return(cimBoot.Response{}, nil) man2.EXPECT(). GetPowerCapabilities(). - Return(boot.BootCapabilitiesResponse{ - ForceUEFIHTTPSBoot: true, - ForceWinREBoot: false, - ForceUEFILocalPBABoot: false, - }, nil) + Return(boot.BootCapabilitiesResponse{}, ErrGeneral) man2.EXPECT(). GetBootData(). - Return(boot.BootSettingDataResponse{ - UEFIHTTPSBootEnabled: true, - WinREBootEnabled: false, - UEFILocalPBABootEnabled: false, - }, nil) + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). @@ -1643,33 +2394,25 @@ func TestSetFeatures(t *testing.T) { Return(device, nil) }, res: dto.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: false, - Redirection: true, - OCR: true, - HTTPSBootSupported: true, - WinREBootSupported: false, - LocalPBABootSupported: false, - RemoteErase: false, + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, }, resV2: dtov2.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: false, - Redirection: true, - KVMAvailable: false, - OCR: true, - HTTPSBootSupported: true, - WinREBootSupported: false, - LocalPBABootSupported: false, + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, }, err: nil, }, { - name: "SetFeatures fails on redirection service set", + name: "SetFeatures fails on setRedirectionService - SetAMTRedirectionService error", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1683,7 +2426,20 @@ func TestSetFeatures(t *testing.T) { Return(1, nil) man2.EXPECT(). GetAMTRedirectionService(). - Return(redirection.Response{}, ErrGeneral) + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + SetAMTRedirectionService(&redirection.RedirectionRequest{ + EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), + ListenerEnabled: true, + }). + Return(redirection.Response{}, ErrGeneral) // SetAMTRedirectionService fails }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). @@ -1704,7 +2460,7 @@ func TestSetFeatures(t *testing.T) { err: ErrGeneral, }, { - name: "SetFeatures fails on user consent", + name: "SetFeatures fails on setUserConsent - SetIPSOptInService error", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1741,7 +2497,20 @@ func TestSetFeatures(t *testing.T) { }, nil) man2.EXPECT(). GetIPSOptInService(). - Return(optin.Response{}, ErrGeneral) + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 0, + }, + }, + }, nil) + man2.EXPECT(). + SetIPSOptInService(optin.OptInServiceRequest{ + OptInRequired: 1, + OptInState: 0, + }). + Return(ErrGeneral) // SetIPSOptInService fails }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). @@ -1764,7 +2533,7 @@ func TestSetFeatures(t *testing.T) { err: ErrGeneral, }, { - name: "SetFeatures fails on boot service state change", + name: "setRPE fails on GetBootCapabilities error", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1816,8 +2585,8 @@ func TestSetFeatures(t *testing.T) { }). Return(nil) man2.EXPECT(). - BootServiceStateChange(32769). - Return(cimBoot.BootService{}, ErrGeneral) + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{}, ErrGeneral) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). @@ -1825,25 +2594,22 @@ func TestSetFeatures(t *testing.T) { Return(device, nil) }, res: dto.Features{ - UserConsent: "kvm", - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - KVMAvailable: true, - Redirection: true, + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, }, resV2: dtov2.Features{ - UserConsent: "kvm", EnableSOL: true, EnableIDER: true, EnableKVM: true, Redirection: true, KVMAvailable: true, }, - err: nil, + err: ErrGeneral, }, { - name: "SetFeatures fails on OCR settings retrieval after successful state change", + name: "setRPE succeeds with PlatformErase supported and enabled", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1895,10 +2661,13 @@ func TestSetFeatures(t *testing.T) { }). Return(nil) man2.EXPECT(). - BootServiceStateChange(32769). - Return(cimBoot.BootService{}, nil) + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 3}, nil) + // BootServiceStateChange fails (non-fatal), so getOneClickRecoverySettings + // is skipped and the RPE fields set by setRPE are preserved. + // OCR=true + RPE=true → state 32771 man2.EXPECT(). - GetBootService(). + BootServiceStateChange(32771). Return(cimBoot.BootService{}, ErrGeneral) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { @@ -1906,12 +2675,32 @@ func TestSetFeatures(t *testing.T) { GetByID(context.Background(), device.GUID, ""). Return(device, nil) }, - res: dto.Features{}, - resV2: dtov2.Features{}, - err: ErrGeneral, + res: dto.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OCR: false, + RPE: true, + RPESupported: true, + }, + resV2: dtov2.Features{ + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OCR: false, + RPE: true, + RPESupported: true, + }, + err: nil, }, { - name: "SetFeatures fails on setRedirectionService - SetAMTRedirectionService error", + name: "setRPE succeeds with OCR and RPE both enabled", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -1938,7 +2727,57 @@ func TestSetFeatures(t *testing.T) { EnabledState: redirection.EnabledState(redirection.EnableIDERAndSOL), ListenerEnabled: true, }). - Return(redirection.Response{}, ErrGeneral) // SetAMTRedirectionService fails + Return(redirection.Response{ + Body: redirection.Body{ + GetAndPutResponse: redirection.RedirectionResponse{ + EnabledState: 32771, + ListenerEnabled: true, + }, + }, + }, nil) + man2.EXPECT(). + GetIPSOptInService(). + Return(optin.Response{ + Body: optin.Body{ + GetAndPutResponse: optin.OptInServiceResponse{ + OptInRequired: 1, + OptInState: 0, + }, + }, + }, nil) + man2.EXPECT(). + SetIPSOptInService(optin.OptInServiceRequest{ + OptInRequired: 1, + OptInState: 0, + }). + Return(nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 1}, nil) + // OCR=true + RPE=true → state 32771 + man2.EXPECT(). + BootServiceStateChange(32771). + Return(cimBoot.BootService{}, nil) + man2.EXPECT(). + GetBootService(). + Return(cimBoot.BootService{EnabledState: 32771}, nil) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{ + Body: cimBoot.Body{ + PullResponse: cimBoot.PullResponse{ + BootSourceSettingItems: []cimBoot.BootSourceSetting{ + {InstanceID: "Intel(r) AMT: Force OCR UEFI HTTPS Boot"}, + }, + }, + }, + }, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{ForceUEFIHTTPSBoot: true, PlatformErase: 1}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{UEFIHTTPSBootEnabled: true}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). @@ -1946,20 +2785,33 @@ func TestSetFeatures(t *testing.T) { Return(device, nil) }, res: dto.Features{ - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OCR: true, + RPE: true, + RPESupported: true, + HTTPSBootSupported: true, }, resV2: dtov2.Features{ - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - KVMAvailable: true, + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OCR: true, + RPE: true, + RPESupported: true, + HTTPSBootSupported: true, }, - err: ErrGeneral, + err: nil, }, { - name: "SetFeatures fails on setUserConsent - SetIPSOptInService error", + name: "RPE only enabled (OCR=false RPE=true state 32770)", action: 0, manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { man.EXPECT(). @@ -2009,7 +2861,26 @@ func TestSetFeatures(t *testing.T) { OptInRequired: 1, OptInState: 0, }). - Return(ErrGeneral) // SetIPSOptInService fails + Return(nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 1}, nil) + // OCR=false + RPE=true → state 32770 + man2.EXPECT(). + BootServiceStateChange(32770). + Return(cimBoot.BootService{}, nil) + man2.EXPECT(). + GetBootService(). + Return(cimBoot.BootService{EnabledState: 32770}, nil) + man2.EXPECT(). + GetCIMBootSourceSetting(). + Return(cimBoot.Response{}, nil) + man2.EXPECT(). + GetPowerCapabilities(). + Return(boot.BootCapabilitiesResponse{PlatformErase: 1}, nil) + man2.EXPECT(). + GetBootData(). + Return(boot.BootSettingDataResponse{}, nil) }, repoMock: func(repo *mocks.MockDeviceManagementRepository) { repo.EXPECT(). @@ -2017,19 +2888,28 @@ func TestSetFeatures(t *testing.T) { Return(device, nil) }, res: dto.Features{ - EnableSOL: true, - EnableIDER: true, - EnableKVM: true, - Redirection: true, + UserConsent: "kvm", + EnableSOL: true, + EnableIDER: true, + EnableKVM: true, + Redirection: true, + KVMAvailable: true, + OCR: false, + RPE: true, + RPESupported: true, }, resV2: dtov2.Features{ + UserConsent: "kvm", EnableSOL: true, EnableIDER: true, EnableKVM: true, Redirection: true, KVMAvailable: true, + OCR: false, + RPE: true, + RPESupported: true, }, - err: ErrGeneral, + err: nil, }, } @@ -2045,11 +2925,19 @@ func TestSetFeatures(t *testing.T) { tc.repoMock(repo) - // Use the appropriate input for the OCR disabled test + // Use the appropriate input for tests that require non-default features var inputFeatures dto.Features - if tc.name == "success with OCR disabled" { + + switch tc.name { + case "success with OCR disabled": inputFeatures = featureSetDisabledOCR - } else { + case "setRPE succeeds with PlatformErase supported and enabled": + inputFeatures = featureSetWithRPE + case "setRPE succeeds with OCR and RPE both enabled": + inputFeatures = featureSetWithRPE + case "RPE only enabled (OCR=false RPE=true state 32770)": + inputFeatures = featureSetRPEOnly + default: inputFeatures = featureSet } diff --git a/internal/usecase/devices/interceptor.go b/internal/usecase/devices/interceptor.go index e20f1ac41..752e00cf8 100644 --- a/internal/usecase/devices/interceptor.go +++ b/internal/usecase/devices/interceptor.go @@ -125,7 +125,7 @@ func (uc *UseCase) getOrCreateConnection(c context.Context, conn *websocket.Conn } func (uc *UseCase) createNewConnection(c context.Context, conn *websocket.Conn, key string, device *entity.Device) (*DeviceConnection, error) { - wsmanConnection, err := uc.redirection.SetupWsmanClient(*device, true, true) + wsmanConnection, err := uc.redirection.SetupWsmanClient(c, *device, true, true) if err != nil { return nil, err } diff --git a/internal/usecase/devices/interceptor_private_test.go b/internal/usecase/devices/interceptor_private_test.go index b44bc7152..1700ac265 100644 --- a/internal/usecase/devices/interceptor_private_test.go +++ b/internal/usecase/devices/interceptor_private_test.go @@ -848,7 +848,7 @@ type spyRedirection struct { listenErr error } -func (s *spyRedirection) SetupWsmanClient(_ entity.Device, _, _ bool) (wsman.Messages, error) { +func (s *spyRedirection) SetupWsmanClient(_ context.Context, _ entity.Device, _, _ bool) (wsman.Messages, error) { return wsman.Messages{}, nil } diff --git a/internal/usecase/devices/interceptor_test.go b/internal/usecase/devices/interceptor_test.go index d8f3825f3..ead512e3e 100644 --- a/internal/usecase/devices/interceptor_test.go +++ b/internal/usecase/devices/interceptor_test.go @@ -66,7 +66,7 @@ func TestRedirect(t *testing.T) { Username: "user", Password: "pass", }, nil) - mockRedir.EXPECT().SetupWsmanClient(gomock.Any(), true, true).Return(wsman.Messages{}, nil) + mockRedir.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), true, true).Return(wsman.Messages{}, nil) mockRedir.EXPECT().RedirectConnect(gomock.Any(), gomock.Any()).Return(ErrInterceptorGeneral) }, expectedErr: ErrInterceptorGeneral, @@ -138,7 +138,7 @@ func TestRedirectSuccessfulFlow(t *testing.T) { // Mock successful flow up to RedirectConnect, then fail to avoid goroutines mockRepo.EXPECT().GetByID(gomock.Any(), testGUID, "").Return(device, nil) - mockRedirection.EXPECT().SetupWsmanClient(*device, true, true).Return(wsman.Messages{}, nil) + mockRedirection.EXPECT().SetupWsmanClient(gomock.Any(), *device, true, true).Return(wsman.Messages{}, nil) // Return error to avoid starting problematic goroutines but still test the flow mockRedirection.EXPECT().RedirectConnect(gomock.Any(), gomock.Any()).Return(ErrConnectionFailed) @@ -211,7 +211,7 @@ func TestRedirectConnectionReuse(t *testing.T) { // First call - create new connection but fail at connect to avoid goroutines mockRepo.EXPECT().GetByID(gomock.Any(), testGUID, "").Return(device, nil) - mockRedirection.EXPECT().SetupWsmanClient(*device, true, true).Return(wsman.Messages{}, nil) + mockRedirection.EXPECT().SetupWsmanClient(gomock.Any(), *device, true, true).Return(wsman.Messages{}, nil) mockRedirection.EXPECT().RedirectConnect(gomock.Any(), gomock.Any()).Return(ErrFirstConnectionFailed) err := uc.Redirect(context.Background(), mockConn, testGUID, testMode) @@ -219,7 +219,7 @@ func TestRedirectConnectionReuse(t *testing.T) { // Second call - also fail to avoid goroutines but test reuse logic mockRepo.EXPECT().GetByID(gomock.Any(), testGUID, "").Return(device, nil) - mockRedirection.EXPECT().SetupWsmanClient(*device, true, true).Return(wsman.Messages{}, nil) + mockRedirection.EXPECT().SetupWsmanClient(gomock.Any(), *device, true, true).Return(wsman.Messages{}, nil) mockRedirection.EXPECT().RedirectConnect(gomock.Any(), gomock.Any()).Return(ErrSecondConnectionFailed) err = uc.Redirect(context.Background(), mockConn, testGUID, testMode) @@ -305,7 +305,7 @@ func TestRedirectWithErrorScenarios(t *testing.T) { device := &entity.Device{GUID: testGUID, Username: "user", Password: "pass"} mockRepo.EXPECT().GetByID(gomock.Any(), testGUID, "").Return(device, nil) - mockRedir.EXPECT().SetupWsmanClient(*device, true, true).Return(wsman.Messages{}, nil) + mockRedir.EXPECT().SetupWsmanClient(gomock.Any(), *device, true, true).Return(wsman.Messages{}, nil) mockRedir.EXPECT().RedirectConnect(gomock.Any(), gomock.Any()).Return(ErrConnectionFailed) }, expectedErr: "connection failed", @@ -383,7 +383,7 @@ func TestRedirectConnectionFlowCoverage(t *testing.T) { device := &entity.Device{GUID: "test-device", Username: "user", Password: "pass"} mockRepo.EXPECT().GetByID(gomock.Any(), "test-device", "").Return(device, nil) - mockRedir.EXPECT().SetupWsmanClient(*device, true, true).Return(wsman.Messages{}, nil) + mockRedir.EXPECT().SetupWsmanClient(gomock.Any(), *device, true, true).Return(wsman.Messages{}, nil) // Return error to avoid starting goroutines, but still exercise connection creation mockRedir.EXPECT().RedirectConnect(gomock.Any(), gomock.Any()).Return(ErrTestError) }, diff --git a/internal/usecase/devices/interfaces.go b/internal/usecase/devices/interfaces.go index 4274ab7be..931f49c43 100644 --- a/internal/usecase/devices/interfaces.go +++ b/internal/usecase/devices/interfaces.go @@ -30,7 +30,7 @@ type ( } Redirection interface { - SetupWsmanClient(device entity.Device, isRedirection, logMessages bool) (wsman.Messages, error) + SetupWsmanClient(ctx context.Context, device entity.Device, isRedirection, logMessages bool) (wsman.Messages, error) RedirectConnect(ctx context.Context, deviceConnection *DeviceConnection) error RedirectClose(ctx context.Context, deviceConnection *DeviceConnection) error RedirectListen(ctx context.Context, deviceConnection *DeviceConnection) ([]byte, error) @@ -72,6 +72,8 @@ type ( GetHardwareInfo(ctx context.Context, guid string) (dto.HardwareInfo, error) GetPowerState(ctx context.Context, guid string) (dto.PowerState, error) GetPowerCapabilities(ctx context.Context, guid string) (dto.PowerCapabilities, error) + GetRemoteEraseCapabilities(ctx context.Context, guid string) (dto.BootCapabilities, error) + SetRemoteEraseOptions(ctx context.Context, guid string, req dto.RemoteEraseRequest) error GetGeneralSettings(ctx context.Context, guid string) (dto.GeneralSettings, error) CancelUserConsent(ctx context.Context, guid string) (dto.UserConsentMessage, error) GetUserConsentCode(ctx context.Context, guid string) (dto.UserConsentMessage, error) diff --git a/internal/usecase/devices/redirection.go b/internal/usecase/devices/redirection.go index f46a0f11d..60c7ce51a 100644 --- a/internal/usecase/devices/redirection.go +++ b/internal/usecase/devices/redirection.go @@ -15,7 +15,7 @@ type Redirector struct { SafeRequirements security.Cryptor } -func (g *Redirector) SetupWsmanClient(device entity.Device, isRedirection, logAMTMessages bool) (wsman.Messages, error) { +func (g *Redirector) SetupWsmanClient(_ context.Context, device entity.Device, isRedirection, logAMTMessages bool) (wsman.Messages, error) { // CIRA device: route redirection through the APF tunnel if isRedirection && device.MPSUsername != "" { connection := wsmanAPI.GetConnectionEntry(device.GUID) diff --git a/internal/usecase/devices/redirection_test.go b/internal/usecase/devices/redirection_test.go index 34b3755fd..8c305edac 100644 --- a/internal/usecase/devices/redirection_test.go +++ b/internal/usecase/devices/redirection_test.go @@ -1,6 +1,7 @@ package devices_test import ( + "context" "net" "testing" @@ -60,7 +61,7 @@ func TestSetupWsmanClient(t *testing.T) { redirector.SafeRequirements = mocks.MockCrypto{} - res, err := redirector.SetupWsmanClient(*device, true, true) + res, err := redirector.SetupWsmanClient(context.Background(), *device, true, true) require.IsType(t, tc.res, res) require.Equal(t, tc.err, err) @@ -81,7 +82,7 @@ func TestSetupWsmanClient_CIRARedirection(t *testing.T) { redirector := &devices.Redirector{SafeRequirements: mocks.MockCrypto{}} - _, err := redirector.SetupWsmanClient(device, true, false) + _, err := redirector.SetupWsmanClient(context.Background(), device, true, false) require.ErrorIs(t, err, wsmanAPI.ErrCIRADeviceNotConnected) }) @@ -108,7 +109,7 @@ func TestSetupWsmanClient_CIRARedirection(t *testing.T) { redirector := &devices.Redirector{SafeRequirements: mocks.MockCrypto{}} - msgs, err := redirector.SetupWsmanClient(device, true, false) + msgs, err := redirector.SetupWsmanClient(context.Background(), device, true, false) require.NoError(t, err) require.NotNil(t, msgs.Client) }) @@ -125,7 +126,7 @@ func TestSetupWsmanClient_CIRARedirection(t *testing.T) { redirector := &devices.Redirector{SafeRequirements: mocks.MockCrypto{}} - msgs, err := redirector.SetupWsmanClient(device, true, false) + msgs, err := redirector.SetupWsmanClient(context.Background(), device, true, false) require.NoError(t, err) require.NotNil(t, msgs) }) @@ -143,7 +144,7 @@ func TestSetupWsmanClient_CIRARedirection(t *testing.T) { redirector := &devices.Redirector{SafeRequirements: mocks.MockCrypto{}} - msgs, err := redirector.SetupWsmanClient(device, false, false) + msgs, err := redirector.SetupWsmanClient(context.Background(), device, false, false) require.NoError(t, err) require.NotNil(t, msgs) }) diff --git a/internal/usecase/devices/wsman/interfaces.go b/internal/usecase/devices/wsman/interfaces.go index bde055f8b..0d5dd7dca 100644 --- a/internal/usecase/devices/wsman/interfaces.go +++ b/internal/usecase/devices/wsman/interfaces.go @@ -50,7 +50,11 @@ type Management interface { GetOSPowerSavingState() (ipspower.OSPowerSavingState, error) GetIPSPowerManagementService() (ipspower.PowerManagementService, error) RequestOSPowerSavingStateChange(osPowerSavingState ipspower.OSPowerSavingState) (ipspower.PowerActionResponse, error) + // GetPowerCapabilities is a compatibility alias for callers that were + // migrated from GetBootCapabilities while still consuming the same raw AMT + // BootCapabilities payload. GetPowerCapabilities() (boot.BootCapabilitiesResponse, error) + GetBootCapabilities() (boot.BootCapabilitiesResponse, error) GetGeneralSettings() (interface{}, error) CancelUserConsentRequest() (optin.Response, error) GetUserConsentCode() (optin.Response, error) @@ -91,4 +95,5 @@ type Management interface { SetIPSKVMRedirectionSettingData(data *kvmredirection.KVMRedirectionSettingsRequest) (kvmredirection.Response, error) DeleteCertificate(instanceID string) error SetLinkPreference(linkPreference, timeout uint32) (int, error) + SetRemoteEraseOptions(eraseMask int, ssdPassword string) error } diff --git a/internal/usecase/devices/wsman/message.go b/internal/usecase/devices/wsman/message.go index 5152601c2..ea4a72396 100644 --- a/internal/usecase/devices/wsman/message.go +++ b/internal/usecase/devices/wsman/message.go @@ -645,6 +645,10 @@ func (c *ConnectionEntry) RequestOSPowerSavingStateChange(newOSPowerStavingState } func (c *ConnectionEntry) GetPowerCapabilities() (boot.BootCapabilitiesResponse, error) { + return c.GetBootCapabilities() +} + +func (c *ConnectionEntry) GetBootCapabilities() (boot.BootCapabilitiesResponse, error) { response, err := c.WsmanMessages.AMT.BootCapabilities.Get() if err != nil { return boot.BootCapabilitiesResponse{}, err diff --git a/internal/usecase/devices/wsman/rpe.go b/internal/usecase/devices/wsman/rpe.go new file mode 100644 index 000000000..565aad519 --- /dev/null +++ b/internal/usecase/devices/wsman/rpe.go @@ -0,0 +1,240 @@ +package wsman + +import ( + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/boot" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" +) + +// TLV constants for the RPE UefiBootParametersArray. +const ( + intelVendorPrefix = 0x8086 // Intel PCI vendor ID used as TLV vendor prefix + rpeTLVValueLen = 4 // TLV value length (uint32 bitmask) + // rpeCSMEBit is the eraseMask bit the UI sets when the user selects + // "Unconfigure Intel CSME Firmware". It is NOT a TLV device-bitmask target; + // it signals that ConfigurationDataReset=true should be set in the PUT body + // and the TLV should be omitted (the C# SDK never uses PlatformErase for CSME). + rpeCSMEBit = 0x10000 +) + +var ( + // ErrRPENotEnabled is returned when RPE is not enabled by the BIOS. + ErrRPENotEnabled = errors.New("remote platform erase is not enabled by the BIOS on this device") + // ErrInvalidEraseMask is returned when an eraseMask is negative. + ErrInvalidEraseMask = errors.New("eraseMask must be non-negative") + // ErrRPEPlatformEraseNotLatched is returned when the PlatformErase flag did not latch after PUT. + ErrRPEPlatformEraseNotLatched = errors.New("remote erase: PlatformErase did not latch; aborting to avoid reboot with erase disabled") + // ErrRPEConfigurationDataResetNotLatched is returned when ConfigurationDataReset did not latch after PUT. + ErrRPEConfigurationDataResetNotLatched = errors.New("remote erase: ConfigurationDataReset did not latch; aborting to avoid reboot without CSME reset") + // ErrRPEPutAndVerifyFailed is returned when both the PUT and the verify GET fail. + ErrRPEPutAndVerifyFailed = errors.New("remote erase: PUT failed and verify GET failed; aborting") + // errRPEPowerStateUnavailable is returned when no power state can be read from AMT. + errRPEPowerStateUnavailable = errors.New("remote erase: power state unavailable") +) + +const ( + // DMTF/CIM current power-state values reported by ServiceAvailableToElement. + // Off states should be powered on (2) rather than cycled (5). + currentPowerStateOffHard = 6 + currentPowerStateOffSoft = 8 + currentPowerStatePowerOffSoftGraceful = 12 + currentPowerStatePowerOffHardGraceful = 13 +) + +func (c *ConnectionEntry) SetRPEEnabled(enabled bool) error { + bootData, err := c.WsmanMessages.AMT.BootSettingData.Get() + if err != nil { + return err + } + + current := bootData.Body.BootSettingDataGetResponse + + _, err = c.WsmanMessages.AMT.BootSettingData.Put(boot.BootSettingDataRequest{ + ElementName: current.ElementName, + InstanceID: current.InstanceID, + OwningEntity: current.OwningEntity, + PlatformErase: enabled, + }) + + return err +} + +func (c *ConnectionEntry) SetRemoteEraseOptions(eraseMask int, ssdPassword string) error { + if eraseMask < 0 { + return ErrInvalidEraseMask + } + + // Step 0: Return boot service to idle (32768) so AMT_BootSettingData.Put is allowed. + // Non-fatal: some firmware versions return ActionNotSupported for this state change. + const enabledStateOCRAndRPEDisabled = 32768 // OCR disabled, RPE disabled + + _, _ = c.WsmanMessages.CIM.BootService.RequestStateChange(enabledStateOCRAndRPEDisabled) + + // Step 1: Attempt to latch PlatformErase=true while the boot service is idle. + // Non-fatal: some firmware versions block this sparse PUT; the full PUT in step 3 + // also sets PlatformErase=true, so the erase can still succeed without this latch. + _ = c.SetRPEEnabled(true) + + // Step 2: Read AMT_BootSettingData to check RPEEnabled and current state. + bootData, err := c.WsmanMessages.AMT.BootSettingData.Get() + if err != nil { + return fmt.Errorf("BootSettingData.Get: %w", err) + } + + current := bootData.Body.BootSettingDataGetResponse + + if !current.RPEEnabled { + return ErrRPENotEnabled + } + + // Separate the CSME-unconfigure signal from the hardware TLV targets early so step 1a + // can be skipped for hardware-only operations (TPM, SSDs, BIOS NVM, ...). + // rpeCSMEBit is NOT a valid TLV device-bitmask bit; it is a UI-level sentinel that tells + // us to set ConfigurationDataReset=true (AMT NV provisioning wipe) in the PUT request. + wantCSMEReset := eraseMask&rpeCSMEBit != 0 + tlvMask := eraseMask &^ rpeCSMEBit // hardware targets only + + // Step 1a (CSME path only): Clear any active boot source override before + // configuring the CSME reset flags. An existing boot source (e.g. from a prior OCR + // session) will take priority and must be cleared first (equivalent to ClearBootOptions + // in the Intel AMT C# SDK). NOT called for hardware-only targets (TPM, SSDs, …) + // because clearing the boot order for those operations causes undefined BIOS behavior. + // Also skipped when hardware TLV targets are present alongside CSME: a combined mask + // should not reach here (the UI enforces CSME-only), but if the API is called directly + // we must not poison the hardware targets by clearing the boot order they depend on. + if wantCSMEReset && tlvMask == 0 { + _, _ = c.WsmanMessages.CIM.BootConfigSetting.ChangeBootOrder("") + } + + // Step 2b: Enable RPE mode in the boot service (32770 = OCR disabled, RPE enabled). + // This is required when the boot service is in OCR mode (32769) from a prior SetFeatures call. + const rpeEnabledState = 32770 // OCR disabled, RPE enabled + + _, _ = c.WsmanMessages.CIM.BootService.RequestStateChange(rpeEnabledState) + + var ( + encodedParams string + uefiBootNumParams int + ) + + if tlvMask != 0 { + encodedParams, uefiBootNumParams = buildRPETLVParams(tlvMask) + } + + // Step 3: PUT the erase flags. + // ConfigurationDataReset=true resets AMT's NV provisioning state (= AMT unprovision). + // PlatformErase=true triggers the RPE UEFI sequence for hardware targets (TPM, SSDs, ...). + // The two are independent: CSME unconfigure never uses PlatformErase (per Intel C# SDK). + putReq := boot.BootSettingDataRequest{ + ElementName: current.ElementName, + InstanceID: current.InstanceID, + OwningEntity: current.OwningEntity, + ConfigurationDataReset: wantCSMEReset, + PlatformErase: tlvMask != 0, + UefiBootParametersArray: encodedParams, + UefiBootNumberOfParams: uefiBootNumParams, + RSEPassword: ssdPassword, + } + + if _, putErr := c.WsmanMessages.AMT.BootSettingData.Put(putReq); putErr != nil { + return c.verifyPutLatched(putErr, wantCSMEReset, tlvMask) + } + + // Step 4: Activate the RPE boot config role so it executes on next restart. + if _, err = c.WsmanMessages.CIM.BootService.SetBootConfigRole("Intel(r) AMT: Boot Configuration 0", 1); err != nil { + return fmt.Errorf("SetBootConfigRole: %w", err) + } + + // Step 5: If the host is currently off, request PowerOn (2). Otherwise, preserve + // existing behavior and request a hard power cycle (5) so the BIOS executes the erase flow. + powerAction, err := c.chooseRPEPowerAction() + if err != nil { + return err + } + + if _, err = c.WsmanMessages.CIM.PowerManagementService.RequestPowerStateChange(powerAction); err != nil { + return fmt.Errorf("RequestPowerStateChange: %w", err) + } + + return nil +} + +func (c *ConnectionEntry) chooseRPEPowerAction() (power.PowerState, error) { + state, err := c.GetPowerState() + if err != nil { + return 0, fmt.Errorf("GetPowerState: %w", err) + } + + if len(state) == 0 { + return 0, errRPEPowerStateUnavailable + } + + return selectRPEPowerAction(int(state[0].PowerState)), nil +} + +func selectRPEPowerAction(currentState int) power.PowerState { + if isOffPowerState(currentState) { + return power.PowerOn + } + + return power.PowerCycleOffHard +} + +func isOffPowerState(currentState int) bool { + switch currentState { + case currentPowerStateOffHard, + currentPowerStateOffSoft, + currentPowerStatePowerOffSoftGraceful, + currentPowerStatePowerOffHardGraceful: + return true + default: + return false + } +} + +// verifyPutLatched is called when BootSettingData.Put returns an error. It issues a +// follow-up GET to check whether the flag that was actually requested (PlatformErase for +// hardware targets, ConfigurationDataReset for CSME-only) latched despite the PUT error. +// If it did not latch, a sentinel error is returned to abort the sequence safely. +func (c *ConnectionEntry) verifyPutLatched(putErr error, wantCSMEReset bool, tlvMask int) error { + verifyData, verifyErr := c.WsmanMessages.AMT.BootSettingData.Get() + if verifyErr != nil { + return fmt.Errorf("%w: PUT err=%w; verify GET err=%w", ErrRPEPutAndVerifyFailed, putErr, verifyErr) + } + + v := verifyData.Body.BootSettingDataGetResponse + + if wantCSMEReset && tlvMask == 0 { + // CSME-only path: the flag that matters is ConfigurationDataReset. + if !v.ConfigurationDataReset { + return fmt.Errorf("%w: PUT err=%w", ErrRPEConfigurationDataResetNotLatched, putErr) + } + + return nil + } + + // Hardware-target path: the flag that matters is PlatformErase. + if !v.PlatformErase { + return fmt.Errorf("%w: PUT err=%w", ErrRPEPlatformEraseNotLatched, putErr) + } + + return nil +} + +// buildRPETLVParams encodes the hardware-target device bitmask as a single RPE TLV entry. +// Format per Intel AMT spec: [vendor:2 LE][typeID:2 LE][length:4 LE][value:4 LE]. +func buildRPETLVParams(tlvMask int) (encodedParams string, numParams int) { + const rpeTLVLen = 12 + + tlvBuf := make([]byte, rpeTLVLen) + binary.LittleEndian.PutUint16(tlvBuf[0:], intelVendorPrefix) // Intel vendor prefix + binary.LittleEndian.PutUint16(tlvBuf[2:], 1) // ParameterTypeID = 1 (device bitmask) + binary.LittleEndian.PutUint32(tlvBuf[4:], rpeTLVValueLen) // value length = 4 bytes + binary.LittleEndian.PutUint32(tlvBuf[8:], uint32(tlvMask)) //nolint:gosec // non-negative, CSME bit already stripped + + return base64.StdEncoding.EncodeToString(tlvBuf), 1 +} diff --git a/internal/usecase/devices/wsman/rpe_test.go b/internal/usecase/devices/wsman/rpe_test.go new file mode 100644 index 000000000..cf69432ce --- /dev/null +++ b/internal/usecase/devices/wsman/rpe_test.go @@ -0,0 +1,158 @@ +package wsman + +import ( + "encoding/base64" + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" +) + +func TestBuildRPETLVParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tlvMask int + wantNumParams int + wantVendor uint16 + wantTypeID uint16 + wantValueLen uint32 + wantMaskInTLV uint32 + }{ + { + name: "TPM Clear only (0x40)", + tlvMask: 0x40, + wantNumParams: 1, + wantVendor: intelVendorPrefix, + wantTypeID: 1, + wantValueLen: rpeTLVValueLen, + wantMaskInTLV: 0x40, + }, + { + name: "BIOS Reload only (0x4000000)", + tlvMask: 0x4000000, + wantNumParams: 1, + wantVendor: intelVendorPrefix, + wantTypeID: 1, + wantValueLen: rpeTLVValueLen, + wantMaskInTLV: 0x4000000, + }, + { + name: "Clear BIOS NVM only (0x2000000)", + tlvMask: 0x2000000, + wantNumParams: 1, + wantVendor: intelVendorPrefix, + wantTypeID: 1, + wantValueLen: rpeTLVValueLen, + wantMaskInTLV: 0x2000000, + }, + { + name: "SSD secure erase only (0x04)", + tlvMask: 0x04, + wantNumParams: 1, + wantVendor: intelVendorPrefix, + wantTypeID: 1, + wantValueLen: rpeTLVValueLen, + wantMaskInTLV: 0x04, + }, + { + name: "TPM + SSD + BIOS NVM + BIOS Reload combined", + tlvMask: 0x40 | 0x04 | 0x2000000 | 0x4000000, + wantNumParams: 1, + wantVendor: intelVendorPrefix, + wantTypeID: 1, + wantValueLen: rpeTLVValueLen, + wantMaskInTLV: 0x40 | 0x04 | 0x2000000 | 0x4000000, + }, + { + name: "CSME bit already stripped (0x10000 absent from tlvMask)", + tlvMask: 0x40, // e.g. 0x10040 &^ rpeCSMEBit = 0x40 + wantNumParams: 1, + wantVendor: intelVendorPrefix, + wantTypeID: 1, + wantValueLen: rpeTLVValueLen, + wantMaskInTLV: 0x40, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + encoded, numParams := buildRPETLVParams(tc.tlvMask) + + assert.Equal(t, tc.wantNumParams, numParams) + + raw, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err, "encoded params must be valid base64") + require.Len(t, raw, 12, "TLV buffer must be exactly 12 bytes") + + gotVendor := binary.LittleEndian.Uint16(raw[0:2]) + gotTypeID := binary.LittleEndian.Uint16(raw[2:4]) + gotValueLen := binary.LittleEndian.Uint32(raw[4:8]) + gotMask := binary.LittleEndian.Uint32(raw[8:12]) + + assert.Equal(t, tc.wantVendor, gotVendor, "vendor prefix") + assert.Equal(t, tc.wantTypeID, gotTypeID, "ParameterTypeID") + assert.Equal(t, tc.wantValueLen, gotValueLen, "value length") + assert.Equal(t, tc.wantMaskInTLV, gotMask, "device bitmask in TLV value") + }) + } +} + +func TestSelectRPEPowerAction(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state int + expectsPower power.PowerState + }{ + { + name: "off hard selects power on (2)", + state: currentPowerStateOffHard, + expectsPower: power.PowerOn, + }, + { + name: "off soft selects power on (2)", + state: currentPowerStateOffSoft, + expectsPower: power.PowerOn, + }, + { + name: "off soft graceful selects power on (2)", + state: currentPowerStatePowerOffSoftGraceful, + expectsPower: power.PowerOn, + }, + { + name: "off hard graceful selects power on (2)", + state: currentPowerStatePowerOffHardGraceful, + expectsPower: power.PowerOn, + }, + { + name: "on selects hard cycle (5)", + state: 2, + expectsPower: power.PowerCycleOffHard, + }, + { + name: "sleep selects hard cycle (5)", + state: 3, + expectsPower: power.PowerCycleOffHard, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.expectsPower, selectRPEPowerAction(tc.state)) + }) + } +}