From 6392a84778648067d16aa1d8625d31ea72eefe4c Mon Sep 17 00:00:00 2001 From: "Loh, Shao Boon" Date: Tue, 7 Apr 2026 01:55:24 +0000 Subject: [PATCH] feat(devices): wireless profile management API Add CRUD wireless profile management to the device surface so operators can list, create, update, and delete Intel AMT WiFi endpoint profiles (including 802.1x/EAP profiles) directly through Console instead of hand-driving WSMAN. New HTTP routes under networkSettings/wireless/profile/{guid} are backed by a devices usecase that talks to extended WSMAN Management operations: it reads existing WiFi and CIM IEEE 802.1x settings, resolves their concrete dependencies, and applies adds/updates while enforcing profile-name and priority uniqueness. For 802.1x profiles the usecase reconciles client/root certificates and private keys against the device, reusing existing credential handles when present and adding them otherwise, with a short pause to let AMT settle certificate handling before the profile is applied. Read responses are sanitized: passwords, CACert, ClientCert, and PrivateKey are never returned, only non-sensitive fields (profile name, SSID, auth/encryption method, priority, and 802.1x username/auth protocol). Request payloads are guarded by custom validators wired into the HTTP router, and the new endpoints are folded into the OpenAPI spec generation flow alongside regenerated mocks, unit tests, and integration tests covering the read/add/update/delete paths. Depends on go-wsman-messages#686 for the update-WiFi-settings message. Refs #834 --- .../console_mps_apis.postman_collection.json | 248 ++++ internal/controller/httpapi/router.go | 28 +- .../controller/httpapi/v1/devicemanagement.go | 4 + internal/controller/httpapi/v1/wifiprofile.go | 105 ++ .../controller/httpapi/v1/wifiprofile_test.go | 227 ++++ internal/controller/httpapi/v1/wifistate.go | 2 +- .../controller/httpapi/v1/wifistate_test.go | 2 +- .../controller/openapi/devicemanagement.go | 70 + internal/controller/ws/v1/interface.go | 5 + internal/entity/dto/v1/wifiprofile.go | 144 ++ internal/entity/dto/v1/wifiprofile_test.go | 276 ++++ internal/mocks/devicemanagement_mocks.go | 58 + internal/mocks/wsman_mocks.go | 107 ++ internal/mocks/wsv1_mocks.go | 58 + internal/usecase/devices/interfaces.go | 5 + internal/usecase/devices/wifiprofile.go | 634 +++++++++ .../devices/wifiprofile_private_test.go | 725 +++++++++++ internal/usecase/devices/wifiprofile_test.go | 1159 +++++++++++++++++ internal/usecase/devices/wifistate.go | 2 +- internal/usecase/devices/wifistate_test.go | 4 +- internal/usecase/devices/wsman/interfaces.go | 10 + internal/usecase/devices/wsman/message.go | 25 + 22 files changed, 3883 insertions(+), 15 deletions(-) create mode 100644 internal/controller/httpapi/v1/wifiprofile.go create mode 100644 internal/controller/httpapi/v1/wifiprofile_test.go create mode 100644 internal/entity/dto/v1/wifiprofile.go create mode 100644 internal/entity/dto/v1/wifiprofile_test.go create mode 100644 internal/usecase/devices/wifiprofile.go create mode 100644 internal/usecase/devices/wifiprofile_private_test.go create mode 100644 internal/usecase/devices/wifiprofile_test.go diff --git a/integration-test/collections/console_mps_apis.postman_collection.json b/integration-test/collections/console_mps_apis.postman_collection.json index e7b29ebfc..3df6dba69 100644 --- a/integration-test/collections/console_mps_apis.postman_collection.json +++ b/integration-test/collections/console_mps_apis.postman_collection.json @@ -913,6 +913,254 @@ } }, "response": [] + }, + { + "name": "Get Wireless Profiles", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Device should not be found\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.error).to.eq(\"Error not found\")\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/networkSettings/wireless/profile/{{deviceId}}", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "networkSettings", + "wireless", + "profile", + "{{deviceId}}" + ] + } + }, + "response": [] + }, + { + "name": "Add Wireless Profile", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Device should not be found\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.error).to.eq(\"Error not found\")\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"profileName\": \"Home\",\r\n \"ssid\": \"HomeSSID\",\r\n \"priority\": 1,\r\n \"authenticationMethod\": \"WPA2PSK\",\r\n \"encryptionMethod\": \"CCMP\",\r\n \"password\": \"password123\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/networkSettings/wireless/profile/{{deviceId}}", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "networkSettings", + "wireless", + "profile", + "{{deviceId}}" + ] + } + }, + "response": [] + }, + { + "name": "Add Duplicate Wireless Profile", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Device should not be found\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.error).to.eq(\"Error not found\")\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"profileName\": \"Home\",\r\n \"ssid\": \"HomeSSID\",\r\n \"priority\": 1,\r\n \"authenticationMethod\": \"WPA2PSK\",\r\n \"encryptionMethod\": \"CCMP\",\r\n \"password\": \"password123\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/networkSettings/wireless/profile/{{deviceId}}", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "networkSettings", + "wireless", + "profile", + "{{deviceId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update Wireless Profile", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Device should not be found\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.error).to.eq(\"Error not found\")\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"profileName\": \"Home\",\r\n \"ssid\": \"HomeSSID-Updated\",\r\n \"priority\": 1,\r\n \"authenticationMethod\": \"WPA2PSK\",\r\n \"encryptionMethod\": \"CCMP\",\r\n \"password\": \"password123\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/networkSettings/wireless/profile/{{deviceId}}", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "networkSettings", + "wireless", + "profile", + "{{deviceId}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete Wireless Profile", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r", + " pm.response.to.have.status(404);\r", + "});\r", + "\r", + "pm.test(\"Device should not be found\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.error).to.eq(\"Error not found\")\r", + "});\r", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/networkSettings/wireless/profile/{{deviceId}}/Home", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "networkSettings", + "wireless", + "profile", + "{{deviceId}}", + "Home" + ] + } + }, + "response": [] } ] }, diff --git a/internal/controller/httpapi/router.go b/internal/controller/httpapi/router.go index 80c69578d..3be2f07b8 100644 --- a/internal/controller/httpapi/router.go +++ b/internal/controller/httpapi/router.go @@ -62,16 +62,7 @@ func NewRouter(handler *gin.Engine, l logger.Interface, t usecase.Usecases, cfg protected = handler.Group("/api", login.JWTAuthMiddleware()) } - // Register custom validators once - if v, ok := binding.Validator.Engine().(*validator.Validate); ok { - if err := v.RegisterValidation("alphanumhyphenunderscore", dto.ValidateAlphaNumHyphenUnderscore); err != nil { - l.Error("failed to register custom validation: " + err.Error()) - } - - if err := v.RegisterValidation("wifistate", dto.ValidateWirelessState); err != nil { - l.Error("failed to register custom validation: " + err.Error()) - } - } + registerCustomValidators(l) // Routers h2 := protected.Group("/v1") @@ -95,3 +86,20 @@ func NewRouter(handler *gin.Engine, l logger.Interface, t usecase.Usecases, cfg v2.NewAmtRoutes(h3, t.Devices, l) } } + +func registerCustomValidators(l logger.Interface) { + v, ok := binding.Validator.Engine().(*validator.Validate) + if !ok { + return + } + + registerValidation := func(tag string, validationFunc validator.Func) { + if err := v.RegisterValidation(tag, validationFunc); err != nil { + l.Error("failed to register custom validation: " + err.Error()) + } + } + + registerValidation("alphanumhyphenunderscore", dto.ValidateAlphaNumHyphenUnderscore) + registerValidation("wifistate", dto.ValidateWirelessState) + registerValidation("wirelessprofile", dto.ValidateWirelessProfile) +} diff --git a/internal/controller/httpapi/v1/devicemanagement.go b/internal/controller/httpapi/v1/devicemanagement.go index 0ca12e54d..dc914f2b7 100644 --- a/internal/controller/httpapi/v1/devicemanagement.go +++ b/internal/controller/httpapi/v1/devicemanagement.go @@ -52,6 +52,10 @@ func NewAmtRoutes(handler *gin.RouterGroup, d devices.Feature, amt amtexplorer.F h.GET("networkSettings/:guid", r.getNetworkSettings) h.GET("networkSettings/wireless/state/:guid", r.getWirelessState) h.POST("networkSettings/wireless/state/:guid", r.requestWirelessStateChange) + 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) diff --git a/internal/controller/httpapi/v1/wifiprofile.go b/internal/controller/httpapi/v1/wifiprofile.go new file mode 100644 index 000000000..dec2da87c --- /dev/null +++ b/internal/controller/httpapi/v1/wifiprofile.go @@ -0,0 +1,105 @@ +package v1 + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" + "github.com/device-management-toolkit/console/pkg/consoleerrors" +) + +var errValidationWirelessProfile = dto.NotValidError{Console: consoleerrors.CreateConsoleError("WirelessProfileAPI")} + +func (r *deviceManagementRoutes) getWirelessProfiles(c *gin.Context) { + guid := c.Param("guid") + + response, err := r.d.GetWirelessProfiles(c.Request.Context(), guid) + if err != nil { + r.l.Error(err, "http - v1 - getWirelessProfiles") + ErrorResponse(c, err) + + return + } + + c.JSON(http.StatusOK, response) +} + +func (r *deviceManagementRoutes) addWirelessProfile(c *gin.Context) { + guid := c.Param("guid") + + var req dto.WirelessProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + validationErr := errValidationWirelessProfile.Wrap("addWirelessProfile", "ShouldBindJSON", err) + ErrorResponse(c, validationErr) + + return + } + + err := r.d.AddWirelessProfile(c.Request.Context(), guid, req.ToWirelessProfile()) + if err != nil { + r.l.Error(err, "http - v1 - addWirelessProfile") + + if errors.Is(err, wsman.ErrNoWiFiPort) { + c.JSON(http.StatusNotFound, gin.H{ + errorKey: "Add Wireless Profile failed for guid: " + guid + ". - " + err.Error(), + }) + + return + } + + ErrorResponse(c, err) + + return + } + + c.Status(http.StatusNoContent) +} + +func (r *deviceManagementRoutes) deleteWirelessProfile(c *gin.Context) { + guid := c.Param("guid") + profileName := c.Param("profileName") + + err := r.d.DeleteWirelessProfile(c.Request.Context(), guid, profileName) + if err != nil { + r.l.Error(err, "http - v1 - deleteWirelessProfile") + ErrorResponse(c, err) + + return + } + + c.Status(http.StatusNoContent) +} + +func (r *deviceManagementRoutes) updateWirelessProfile(c *gin.Context) { + guid := c.Param("guid") + + var req dto.WirelessProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + validationErr := errValidationWirelessProfile.Wrap("updateWirelessProfile", "ShouldBindJSON", err) + ErrorResponse(c, validationErr) + + return + } + + err := r.d.UpdateWirelessProfile(c.Request.Context(), guid, req.ToWirelessProfile()) + if err != nil { + r.l.Error(err, "http - v1 - updateWirelessProfile") + + if errors.Is(err, wsman.ErrNoWiFiPort) { + c.JSON(http.StatusNotFound, gin.H{ + errorKey: "Update Wireless Profile failed for guid: " + guid + ". - " + err.Error(), + }) + + return + } + + ErrorResponse(c, err) + + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/controller/httpapi/v1/wifiprofile_test.go b/internal/controller/httpapi/v1/wifiprofile_test.go new file mode 100644 index 000000000..0ed223506 --- /dev/null +++ b/internal/controller/httpapi/v1/wifiprofile_test.go @@ -0,0 +1,227 @@ +package v1 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + + dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/mocks" + "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" +) + +type wiFiProfileRouteTest struct { + name string + method string + url string + mock func(*mocks.MockDeviceManagementFeature) + requestBody interface{} + rawBody string + response interface{} + expectedCode int +} + +func TestWiFiProfileRoutes(t *testing.T) { //nolint:gocognit // table-driven HTTP route coverage with many scenarios + t.Parallel() + + request := config.WirelessProfile{ + ProfileName: "office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Password: "password123", + } + + expectedProfiles := []dto.WirelessProfileResponse{{ + ProfileName: "office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }} + + tests := []wiFiProfileRouteTest{ + { + name: "get wireless profiles", + method: http.MethodGet, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().GetWirelessProfiles(context.Background(), "device-guid").Return(expectedProfiles, nil) + }, + response: expectedProfiles, + expectedCode: http.StatusOK, + }, + { + name: "get wireless profiles - service failure", + method: http.MethodGet, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().GetWirelessProfiles(context.Background(), "device-guid").Return(nil, ErrGeneral) + }, + expectedCode: http.StatusInternalServerError, + }, + { + name: "add wireless profile", + method: http.MethodPost, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + requestBody: request, + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().AddWirelessProfile(context.Background(), "device-guid", request).Return(nil) + }, + expectedCode: http.StatusNoContent, + }, + { + name: "add wireless profile - bind failure", + method: http.MethodPost, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + rawBody: `{"profileName":`, + expectedCode: http.StatusBadRequest, + }, + { + name: "add wireless profile - service failure", + method: http.MethodPost, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + requestBody: request, + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().AddWirelessProfile(context.Background(), "device-guid", request).Return(ErrGeneral) + }, + expectedCode: http.StatusInternalServerError, + }, + { + name: "add wireless profile - no wifi port", + method: http.MethodPost, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + requestBody: request, + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().AddWirelessProfile(context.Background(), "device-guid", request).Return(wsman.ErrNoWiFiPort) + }, + expectedCode: http.StatusNotFound, + }, + { + name: "update wireless profile", + method: http.MethodPatch, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + requestBody: request, + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().UpdateWirelessProfile(context.Background(), "device-guid", request).Return(nil) + }, + expectedCode: http.StatusNoContent, + }, + { + name: "update wireless profile - uses body profile name", + method: http.MethodPatch, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + requestBody: config.WirelessProfile{ProfileName: "guest", SSID: "CorpNet", Priority: 1, AuthenticationMethod: "WPA2PSK", EncryptionMethod: "CCMP", Password: "password123"}, + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().UpdateWirelessProfile(context.Background(), "device-guid", config.WirelessProfile{ProfileName: "guest", SSID: "CorpNet", Priority: 1, AuthenticationMethod: "WPA2PSK", EncryptionMethod: "CCMP", Password: "password123"}).Return(nil) + }, + expectedCode: http.StatusNoContent, + }, + { + name: "update wireless profile - bind failure", + method: http.MethodPatch, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + rawBody: `{"profileName":`, + expectedCode: http.StatusBadRequest, + }, + { + name: "update wireless profile - service failure", + method: http.MethodPatch, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + requestBody: request, + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().UpdateWirelessProfile(context.Background(), "device-guid", request).Return(ErrGeneral) + }, + expectedCode: http.StatusInternalServerError, + }, + { + name: "update wireless profile - no wifi port", + method: http.MethodPatch, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid", + requestBody: request, + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().UpdateWirelessProfile(context.Background(), "device-guid", request).Return(wsman.ErrNoWiFiPort) + }, + expectedCode: http.StatusNotFound, + }, + { + name: "delete wireless profile", + method: http.MethodDelete, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid/office", + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().DeleteWirelessProfile(context.Background(), "device-guid", "office").Return(nil) + }, + expectedCode: http.StatusNoContent, + }, + { + name: "delete wireless profile - service failure", + method: http.MethodDelete, + url: "/api/v1/amt/networkSettings/wireless/profile/device-guid/office", + mock: func(feature *mocks.MockDeviceManagementFeature) { + feature.EXPECT().DeleteWirelessProfile(context.Background(), "device-guid", "office").Return(ErrGeneral) + }, + expectedCode: http.StatusInternalServerError, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + feature, engine := deviceManagementTest(t) + + if tc.mock != nil { + tc.mock(feature) + } + + var req *http.Request + + var err error + + switch tc.method { + case http.MethodPost, http.MethodPatch: + if tc.rawBody != "" { + req, err = http.NewRequestWithContext(context.Background(), tc.method, tc.url, bytes.NewBufferString(tc.rawBody)) + } else { + payload, marshalErr := json.Marshal(tc.requestBody) + require.NoError(t, marshalErr) + + req, err = http.NewRequestWithContext(context.Background(), tc.method, tc.url, bytes.NewBuffer(payload)) + } + + req.Header.Set("Content-Type", "application/json") + default: + req, err = http.NewRequestWithContext(context.Background(), tc.method, tc.url, 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, marshalErr := json.Marshal(tc.response) + require.NoError(t, marshalErr) + require.Equal(t, string(jsonBytes), w.Body.String()) + + return + } + + if tc.expectedCode == http.StatusNoContent { + require.Empty(t, w.Body.String()) + } + }) + } +} diff --git a/internal/controller/httpapi/v1/wifistate.go b/internal/controller/httpapi/v1/wifistate.go index dd0471c07..8959c7d83 100644 --- a/internal/controller/httpapi/v1/wifistate.go +++ b/internal/controller/httpapi/v1/wifistate.go @@ -8,7 +8,7 @@ import ( "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" - dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/entity/dto/v1" "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" "github.com/device-management-toolkit/console/pkg/consoleerrors" ) diff --git a/internal/controller/httpapi/v1/wifistate_test.go b/internal/controller/httpapi/v1/wifistate_test.go index 76eaee671..029016fb9 100644 --- a/internal/controller/httpapi/v1/wifistate_test.go +++ b/internal/controller/httpapi/v1/wifistate_test.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" - gomock "go.uber.org/mock/gomock" + "go.uber.org/mock/gomock" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" diff --git a/internal/controller/openapi/devicemanagement.go b/internal/controller/openapi/devicemanagement.go index 4d1087f77..305dc5e6e 100644 --- a/internal/controller/openapi/devicemanagement.go +++ b/internal/controller/openapi/devicemanagement.go @@ -5,6 +5,8 @@ import ( "github.com/go-fuego/fuego" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + "github.com/device-management-toolkit/console/internal/entity/dto/v1" ) @@ -118,6 +120,42 @@ func (f *FuegoAdapter) registerNetworkAndFeatureRoutes() { protectedRouteOptions(), ) + fuego.Get(f.server, "/api/v1/amt/networkSettings/wireless/profile/{guid}", f.getWirelessProfiles, + fuego.OptionTags("Device Management"), + fuego.OptionSummary("Get Wireless Profiles"), + fuego.OptionDescription("Retrieve configured wireless profiles for a device"), + fuego.OptionPath("guid", "Device GUID"), + protectedRouteOptions(), + ) + + fuego.Post(f.server, "/api/v1/amt/networkSettings/wireless/profile/{guid}", f.addWirelessProfile, + fuego.OptionTags("Device Management"), + fuego.OptionSummary("Create Wireless Profile"), + fuego.OptionDescription("Create a wireless profile on a device"), + fuego.OptionPath("guid", "Device GUID"), + fuego.OptionDefaultStatusCode(http.StatusNoContent), + protectedRouteOptions(), + ) + + fuego.Patch(f.server, "/api/v1/amt/networkSettings/wireless/profile/{guid}", f.updateWirelessProfile, + fuego.OptionTags("Device Management"), + fuego.OptionSummary("Update Wireless Profile"), + fuego.OptionDescription("Update a wireless profile on a device"), + fuego.OptionPath("guid", "Device GUID"), + fuego.OptionDefaultStatusCode(http.StatusNoContent), + protectedRouteOptions(), + ) + + fuego.Delete(f.server, "/api/v1/amt/networkSettings/wireless/profile/{guid}/{profileName}", f.deleteWirelessProfile, + fuego.OptionTags("Device Management"), + fuego.OptionSummary("Delete Wireless Profile"), + fuego.OptionDescription("Delete a wireless profile from a device"), + fuego.OptionPath("guid", "Device GUID"), + fuego.OptionPath("profileName", "Wireless profile name"), + fuego.OptionDefaultStatusCode(http.StatusNoContent), + protectedRouteOptions(), + ) + fuego.Post(f.server, "/api/v1/amt/network/linkPreference/{guid}", f.setLinkPreference, fuego.OptionTags("Device Management"), fuego.OptionSummary("Set Link Preference"), @@ -413,6 +451,38 @@ func (f *FuegoAdapter) requestWirelessStateChange(c fuego.ContextWithBody[dto.Wi return dto.WirelessStateResponse(req), nil } +func (f *FuegoAdapter) getWirelessProfiles(_ fuego.ContextNoBody) ([]dto.WirelessProfileResponse, error) { + return []dto.WirelessProfileResponse{{ + ProfileName: "OfficeWiFi", + SSID: "OfficeSSID", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }}, nil +} + +func (f *FuegoAdapter) addWirelessProfile(c fuego.ContextWithBody[config.WirelessProfile]) (NoContentResponse, error) { + _, err := c.Body() + if err != nil { + return NoContentResponse{}, err + } + + return NoContentResponse{}, nil +} + +func (f *FuegoAdapter) updateWirelessProfile(c fuego.ContextWithBody[config.WirelessProfile]) (NoContentResponse, error) { + _, err := c.Body() + if err != nil { + return NoContentResponse{}, err + } + + return NoContentResponse{}, nil +} + +func (f *FuegoAdapter) deleteWirelessProfile(_ fuego.ContextNoBody) (NoContentResponse, error) { + return NoContentResponse{}, nil +} + func (f *FuegoAdapter) setLinkPreference(c fuego.ContextWithBody[dto.LinkPreferenceRequest]) (dto.LinkPreferenceResponse, error) { _, err := c.Body() if err != nil { diff --git a/internal/controller/ws/v1/interface.go b/internal/controller/ws/v1/interface.go index a915ee00c..0940eb887 100644 --- a/internal/controller/ws/v1/interface.go +++ b/internal/controller/ws/v1/interface.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" @@ -61,6 +62,10 @@ type Feature interface { GetNetworkSettings(c context.Context, guid string) (dto.NetworkSettings, error) RequestWirelessStateChange(c context.Context, guid string, requestedState wifi.RequestedState) (wifi.RequestedState, error) GetWirelessState(c context.Context, guid string) (wifi.EnabledState, error) + GetWirelessProfiles(c context.Context, guid string) ([]dto.WirelessProfileResponse, error) + AddWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error + DeleteWirelessProfile(c context.Context, guid, profileName string) error + UpdateWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error GetCertificates(c context.Context, guid string) (dto.SecuritySettings, error) GetTLSSettingData(c context.Context, guid string) ([]dto.SettingDataResponse, error) GetDiskInfo(c context.Context, guid string) (dto.DiskInfo, error) diff --git a/internal/entity/dto/v1/wifiprofile.go b/internal/entity/dto/v1/wifiprofile.go new file mode 100644 index 000000000..17fb7edbc --- /dev/null +++ b/internal/entity/dto/v1/wifiprofile.go @@ -0,0 +1,144 @@ +package dto + +import ( + "encoding/json" + "regexp" + + "github.com/go-playground/validator/v10" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" +) + +const ( + maxWirelessProfilePriority = 255 +) + +// WirelessProfileRequest carries one wireless profile payload for create/update APIs. +type WirelessProfileRequest struct { + Profile config.WirelessProfile `json:"-" binding:"wirelessprofile"` +} + +// UnmarshalJSON maps a flat profile payload into the dto request wrapper. +func (r *WirelessProfileRequest) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &r.Profile) +} + +// ToWirelessProfile converts request payload into WSMan wireless profile config. +func (r WirelessProfileRequest) ToWirelessProfile() config.WirelessProfile { + return r.Profile +} + +// WirelessProfileResponse is the sanitized wireless profile returned by read APIs. +// Sensitive fields (passwords, certificates, and private keys) are intentionally omitted. +type WirelessProfileResponse struct { + ProfileName string `json:"profileName"` + SSID string `json:"ssid"` + AuthenticationMethod string `json:"authenticationMethod"` + EncryptionMethod string `json:"encryptionMethod"` + Priority int `json:"priority"` + IEEE8021x *WirelessIEEE8021xResponse `json:"ieee8021x,omitempty"` +} + +// WirelessIEEE8021xResponse is the sanitized IEEE 802.1x section of a wireless profile. +type WirelessIEEE8021xResponse struct { + Username string `json:"username"` + AuthenticationProtocol int `json:"authenticationProtocol"` + PXETimeout int `json:"pxeTimeout,omitempty"` +} + +// NewWirelessProfileResponse maps a config.WirelessProfile to its sanitized response form. +func NewWirelessProfileResponse(profile config.WirelessProfile) WirelessProfileResponse { + resp := WirelessProfileResponse{ + ProfileName: profile.ProfileName, + SSID: profile.SSID, + AuthenticationMethod: profile.AuthenticationMethod, + EncryptionMethod: profile.EncryptionMethod, + Priority: profile.Priority, + } + + if profile.IEEE8021x != nil { + resp.IEEE8021x = &WirelessIEEE8021xResponse{ + Username: profile.IEEE8021x.Username, + AuthenticationProtocol: profile.IEEE8021x.AuthenticationProtocol, + PXETimeout: profile.IEEE8021x.PXETimeout, + } + } + + return resp +} + +// NewWirelessProfileResponses maps a slice of config.WirelessProfile to sanitized responses. +func NewWirelessProfileResponses(profiles []config.WirelessProfile) []WirelessProfileResponse { + responses := make([]WirelessProfileResponse, 0, len(profiles)) + for i := range profiles { + responses = append(responses, NewWirelessProfileResponse(profiles[i])) + } + + return responses +} + +var reAlphaNumWirelessProfileName = regexp.MustCompile("^[a-zA-Z0-9]+$") + +// ValidateWirelessProfile validates one shared config.WirelessProfile payload. +var ValidateWirelessProfile validator.Func = func(fl validator.FieldLevel) bool { + profile, ok := fl.Field().Interface().(config.WirelessProfile) + if !ok { + return false + } + + if !isValidWirelessProfileBase(profile) { + return false + } + + authMethod, ok := wifi.ParseAuthenticationMethod(profile.AuthenticationMethod) + if !ok { + return false + } + + if _, ok = wifi.ParseEncryptionMethod(profile.EncryptionMethod); !ok { + return false + } + + return hasValidWirelessProfileCredentials(profile, authMethod) +} + +func isValidWirelessProfileBase(profile config.WirelessProfile) bool { + if profile.ProfileName == "" || !reAlphaNumWirelessProfileName.MatchString(profile.ProfileName) { + return false + } + + if profile.SSID == "" || profile.Priority <= 0 || profile.Priority > maxWirelessProfilePriority { + return false + } + + return true +} + +func hasValidWirelessProfileCredentials(profile config.WirelessProfile, authMethod wifi.AuthenticationMethod) bool { + if isPSKAuthenticationMethod(authMethod) { + return profile.Password != "" && profile.IEEE8021x == nil + } + + if isIEEE8021xAuthenticationMethod(authMethod) { + return hasValidIEEE8021xCredentials(profile) + } + + return false +} + +func isPSKAuthenticationMethod(authMethod wifi.AuthenticationMethod) bool { + return authMethod == wifi.AuthenticationMethodWPAPSK || authMethod == wifi.AuthenticationMethodWPA2PSK +} + +func isIEEE8021xAuthenticationMethod(authMethod wifi.AuthenticationMethod) bool { + return authMethod == wifi.AuthenticationMethodWPAIEEE8021x || authMethod == wifi.AuthenticationMethodWPA2IEEE8021x +} + +func hasValidIEEE8021xCredentials(profile config.WirelessProfile) bool { + if profile.IEEE8021x == nil || profile.Password != "" { + return false + } + + return profile.IEEE8021x.AuthenticationProtocol == 0 || profile.IEEE8021x.AuthenticationProtocol == 2 +} diff --git a/internal/entity/dto/v1/wifiprofile_test.go b/internal/entity/dto/v1/wifiprofile_test.go new file mode 100644 index 000000000..dcef72f69 --- /dev/null +++ b/internal/entity/dto/v1/wifiprofile_test.go @@ -0,0 +1,276 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/go-playground/validator/v10" + "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" +) + +func TestValidateWirelessProfile(t *testing.T) { + t.Parallel() + + validate := validator.New() + require.NoError(t, validate.RegisterValidation("wirelessprofile", ValidateWirelessProfile)) + + type profileWrapper struct { + Profile config.WirelessProfile `validate:"wirelessprofile"` + } + + tests := []struct { + name string + profile config.WirelessProfile + wantErr bool + }{ + { + name: "valid psk profile", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Password: "password123", + }, + wantErr: false, + }, + { + name: "valid ieee8021x profile", + profile: config.WirelessProfile{ + ProfileName: "OfficeEAP", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + AuthenticationProtocol: 0, + Username: "user", + }, + }, + wantErr: false, + }, + { + name: "invalid profile name", + profile: config.WirelessProfile{ + ProfileName: "office-net", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Password: "password123", + }, + wantErr: true, + }, + { + name: "invalid auth method", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "OpenSystem", + EncryptionMethod: "CCMP", + Password: "password123", + }, + wantErr: true, + }, + { + name: "auth parse failure", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "NotRealAuthMethod", + EncryptionMethod: "CCMP", + Password: "password123", + }, + wantErr: true, + }, + { + name: "encryption method accepted by parser", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "WEP", + Password: "password123", + }, + wantErr: false, + }, + { + name: "encryption parse failure", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "NotRealEncryptionMethod", + Password: "password123", + }, + wantErr: true, + }, + { + name: "invalid ssid priority guard", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Password: "password123", + }, + wantErr: true, + }, + { + name: "invalid priority out of range", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 256, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Password: "password123", + }, + wantErr: true, + }, + { + name: "psk auth missing password", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + wantErr: true, + }, + { + name: "ieee8021x auth missing settings", + profile: config.WirelessProfile{ + ProfileName: "OfficeEAP", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + }, + wantErr: true, + }, + { + name: "ieee8021x invalid authentication protocol", + profile: config.WirelessProfile{ + ProfileName: "OfficeEAP", + SSID: "CorpNet", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + AuthenticationProtocol: 1, + Username: "user", + }, + }, + wantErr: true, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validate.Struct(profileWrapper{Profile: tc.profile}) + if tc.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestValidateWirelessProfileTypeAssertionFailure(t *testing.T) { + t.Parallel() + + validate := validator.New() + require.NoError(t, validate.RegisterValidation("wirelessprofile", ValidateWirelessProfile)) + + type wrongProfileWrapper struct { + Profile interface{} `validate:"wirelessprofile"` + } + + err := validate.Struct(wrongProfileWrapper{Profile: "not-a-wireless-profile"}) + require.Error(t, err) +} + +func TestHasValidWirelessProfileCredentialsUnsupportedAuthMethod(t *testing.T) { + t.Parallel() + + profile := config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Priority: 1, + } + + require.False(t, hasValidWirelessProfileCredentials(profile, wifi.AuthenticationMethod(255))) +} + +func TestWirelessProfileRequestValidation(t *testing.T) { + t.Parallel() + + validate := validator.New() + validate.SetTagName("binding") + require.NoError(t, validate.RegisterValidation("wirelessprofile", ValidateWirelessProfile)) + + t.Run("valid request", func(t *testing.T) { + t.Parallel() + + var req WirelessProfileRequest + + err := json.Unmarshal([]byte(`{"profileName":"Office","ssid":"CorpNet","priority":1,"authenticationMethod":"WPA2PSK","encryptionMethod":"CCMP","password":"password123"}`), &req) + require.NoError(t, err) + + require.NoError(t, validate.Struct(req)) + }) + + t.Run("invalid request", func(t *testing.T) { + t.Parallel() + + var req WirelessProfileRequest + + err := json.Unmarshal([]byte(`{"profileName":"office-net","ssid":"","priority":0,"authenticationMethod":"bad-auth","encryptionMethod":"bad-encryption"}`), &req) + require.NoError(t, err) + + require.Error(t, validate.Struct(req)) + }) +} + +func TestWirelessProfileRequestToWirelessProfile(t *testing.T) { + t.Parallel() + + req := WirelessProfileRequest{ + Profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "CorpNet", + Password: "password123", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Priority: 1, + IEEE8021x: &config.IEEE8021x{AuthenticationProtocol: 0, Username: "user"}, + }, + } + + profile := req.ToWirelessProfile() + + require.Equal(t, req.Profile.ProfileName, profile.ProfileName) + require.Equal(t, req.Profile.SSID, profile.SSID) + require.Equal(t, req.Profile.Password, profile.Password) + require.Equal(t, req.Profile.AuthenticationMethod, profile.AuthenticationMethod) + require.Equal(t, req.Profile.EncryptionMethod, profile.EncryptionMethod) + require.Equal(t, req.Profile.Priority, profile.Priority) + require.Equal(t, req.Profile.IEEE8021x, profile.IEEE8021x) +} diff --git a/internal/mocks/devicemanagement_mocks.go b/internal/mocks/devicemanagement_mocks.go index 998721050..7e2ac64a2 100644 --- a/internal/mocks/devicemanagement_mocks.go +++ b/internal/mocks/devicemanagement_mocks.go @@ -18,6 +18,7 @@ import ( v2 "github.com/device-management-toolkit/console/internal/entity/dto/v2" devices "github.com/device-management-toolkit/console/internal/usecase/devices" wsman "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" + config "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" wsman0 "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman" power "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" wifi "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" @@ -478,6 +479,20 @@ func (mr *MockDeviceManagementFeatureMockRecorder) AddCertificate(c, guid, certI return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddCertificate", reflect.TypeOf((*MockDeviceManagementFeature)(nil).AddCertificate), c, guid, certInfo) } +// AddWirelessProfile mocks base method. +func (m *MockDeviceManagementFeature) AddWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddWirelessProfile", c, guid, profile) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddWirelessProfile indicates an expected call of AddWirelessProfile. +func (mr *MockDeviceManagementFeatureMockRecorder) AddWirelessProfile(c, guid, profile any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddWirelessProfile", reflect.TypeOf((*MockDeviceManagementFeature)(nil).AddWirelessProfile), c, guid, profile) +} + // CancelUserConsent mocks base method. func (m *MockDeviceManagementFeature) CancelUserConsent(ctx context.Context, guid string) (dto.UserConsentMessage, error) { m.ctrl.T.Helper() @@ -550,6 +565,20 @@ func (mr *MockDeviceManagementFeatureMockRecorder) DeleteCertificate(c, guid, in return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificate", reflect.TypeOf((*MockDeviceManagementFeature)(nil).DeleteCertificate), c, guid, instanceID) } +// DeleteWirelessProfile mocks base method. +func (m *MockDeviceManagementFeature) DeleteWirelessProfile(c context.Context, guid, profileName string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWirelessProfile", c, guid, profileName) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWirelessProfile indicates an expected call of DeleteWirelessProfile. +func (mr *MockDeviceManagementFeatureMockRecorder) DeleteWirelessProfile(c, guid, profileName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWirelessProfile", reflect.TypeOf((*MockDeviceManagementFeature)(nil).DeleteWirelessProfile), c, guid, profileName) +} + // Get mocks base method. func (m *MockDeviceManagementFeature) Get(ctx context.Context, top, skip int, tenantID string) ([]dto.Device, error) { m.ctrl.T.Helper() @@ -897,6 +926,21 @@ func (mr *MockDeviceManagementFeatureMockRecorder) GetVersion(ctx, guid any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockDeviceManagementFeature)(nil).GetVersion), ctx, guid) } +// GetWirelessProfiles mocks base method. +func (m *MockDeviceManagementFeature) GetWirelessProfiles(c context.Context, guid string) ([]dto.WirelessProfileResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWirelessProfiles", c, guid) + ret0, _ := ret[0].([]dto.WirelessProfileResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWirelessProfiles indicates an expected call of GetWirelessProfiles. +func (mr *MockDeviceManagementFeatureMockRecorder) GetWirelessProfiles(c, guid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWirelessProfiles", reflect.TypeOf((*MockDeviceManagementFeature)(nil).GetWirelessProfiles), c, guid) +} + // GetWirelessState mocks base method. func (m *MockDeviceManagementFeature) GetWirelessState(c context.Context, guid string) (wifi.EnabledState, error) { m.ctrl.T.Helper() @@ -1089,3 +1133,17 @@ func (mr *MockDeviceManagementFeatureMockRecorder) UpdateLastSeen(ctx, guid any) mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastSeen", reflect.TypeOf((*MockDeviceManagementFeature)(nil).UpdateLastSeen), ctx, guid) } + +// UpdateWirelessProfile mocks base method. +func (m *MockDeviceManagementFeature) UpdateWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWirelessProfile", c, guid, profile) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWirelessProfile indicates an expected call of UpdateWirelessProfile. +func (mr *MockDeviceManagementFeatureMockRecorder) UpdateWirelessProfile(c, guid, profile any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWirelessProfile", reflect.TypeOf((*MockDeviceManagementFeature)(nil).UpdateWirelessProfile), c, guid, profile) +} diff --git a/internal/mocks/wsman_mocks.go b/internal/mocks/wsman_mocks.go index efadc96fe..4abb49d88 100644 --- a/internal/mocks/wsman_mocks.go +++ b/internal/mocks/wsman_mocks.go @@ -22,10 +22,13 @@ import ( redirection "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/redirection" setupandconfiguration "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/setupandconfiguration" tls0 "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/tls" + wifiportconfiguration "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/wifiportconfiguration" boot0 "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/boot" concrete "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/concrete" credential "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/credential" + ieee8021x "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/ieee8021x" kvm "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/kvm" + models "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/models" power "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" service "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/service" software "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/software" @@ -77,6 +80,21 @@ func (mr *MockManagementMockRecorder) AddClientCert(clientCert any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddClientCert", reflect.TypeOf((*MockManagement)(nil).AddClientCert), clientCert) } +// AddPrivateKey mocks base method. +func (m *MockManagement) AddPrivateKey(privateKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPrivateKey", privateKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddPrivateKey indicates an expected call of AddPrivateKey. +func (mr *MockManagementMockRecorder) AddPrivateKey(privateKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPrivateKey", reflect.TypeOf((*MockManagement)(nil).AddPrivateKey), privateKey) +} + // AddTrustedRootCert mocks base method. func (m *MockManagement) AddTrustedRootCert(caCert string) (string, error) { m.ctrl.T.Helper() @@ -92,6 +110,21 @@ func (mr *MockManagementMockRecorder) AddTrustedRootCert(caCert any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTrustedRootCert", reflect.TypeOf((*MockManagement)(nil).AddTrustedRootCert), caCert) } +// AddWiFiSettings mocks base method. +func (m *MockManagement) AddWiFiSettings(wifiEndpointSettings wifi.WiFiEndpointSettingsRequest, ieee8021xSettings models.IEEE8021xSettings, wifiEndpoint, clientCredential, caCredential string) (wifiportconfiguration.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddWiFiSettings", wifiEndpointSettings, ieee8021xSettings, wifiEndpoint, clientCredential, caCredential) + ret0, _ := ret[0].(wifiportconfiguration.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddWiFiSettings indicates an expected call of AddWiFiSettings. +func (mr *MockManagementMockRecorder) AddWiFiSettings(wifiEndpointSettings, ieee8021xSettings, wifiEndpoint, clientCredential, caCredential any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddWiFiSettings", reflect.TypeOf((*MockManagement)(nil).AddWiFiSettings), wifiEndpointSettings, ieee8021xSettings, wifiEndpoint, clientCredential, caCredential) +} + // BootServiceStateChange mocks base method. func (m *MockManagement) BootServiceStateChange(requestedState int) (boot0.BootService, error) { m.ctrl.T.Helper() @@ -180,6 +213,20 @@ func (mr *MockManagementMockRecorder) DeleteCertificate(instanceID any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificate", reflect.TypeOf((*MockManagement)(nil).DeleteCertificate), instanceID) } +// DeleteWiFiSetting mocks base method. +func (m *MockManagement) DeleteWiFiSetting(instanceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWiFiSetting", instanceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWiFiSetting indicates an expected call of DeleteWiFiSetting. +func (mr *MockManagementMockRecorder) DeleteWiFiSetting(instanceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWiFiSetting", reflect.TypeOf((*MockManagement)(nil).DeleteWiFiSetting), instanceID) +} + // EnumerateWiFiPort mocks base method. func (m *MockManagement) EnumerateWiFiPort() (wifi.Response, error) { m.ctrl.T.Helper() @@ -300,6 +347,21 @@ func (mr *MockManagementMockRecorder) GetCIMBootSourceSetting() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCIMBootSourceSetting", reflect.TypeOf((*MockManagement)(nil).GetCIMBootSourceSetting)) } +// GetCIMIEEE8021xSettings mocks base method. +func (m *MockManagement) GetCIMIEEE8021xSettings() (ieee8021x.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCIMIEEE8021xSettings") + ret0, _ := ret[0].(ieee8021x.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCIMIEEE8021xSettings indicates an expected call of GetCIMIEEE8021xSettings. +func (mr *MockManagementMockRecorder) GetCIMIEEE8021xSettings() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCIMIEEE8021xSettings", reflect.TypeOf((*MockManagement)(nil).GetCIMIEEE8021xSettings)) +} + // GetCertificates mocks base method. func (m *MockManagement) GetCertificates() (wsman.Certificates, error) { m.ctrl.T.Helper() @@ -600,6 +662,36 @@ func (mr *MockManagementMockRecorder) GetUserConsentCode() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserConsentCode", reflect.TypeOf((*MockManagement)(nil).GetUserConsentCode)) } +// GetWiFiPorts mocks base method. +func (m *MockManagement) GetWiFiPorts() ([]wifi.WiFiPort, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWiFiPorts") + ret0, _ := ret[0].([]wifi.WiFiPort) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWiFiPorts indicates an expected call of GetWiFiPorts. +func (mr *MockManagementMockRecorder) GetWiFiPorts() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWiFiPorts", reflect.TypeOf((*MockManagement)(nil).GetWiFiPorts)) +} + +// GetWiFiSettings mocks base method. +func (m *MockManagement) GetWiFiSettings() ([]wifi.WiFiEndpointSettingsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWiFiSettings") + ret0, _ := ret[0].([]wifi.WiFiEndpointSettingsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWiFiSettings indicates an expected call of GetWiFiSettings. +func (mr *MockManagementMockRecorder) GetWiFiSettings() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWiFiSettings", reflect.TypeOf((*MockManagement)(nil).GetWiFiSettings)) +} + // PullWiFiPort mocks base method. func (m *MockManagement) PullWiFiPort(enumerationContext string) (wifi.Response, error) { m.ctrl.T.Helper() @@ -780,6 +872,21 @@ func (mr *MockManagementMockRecorder) SetLinkPreference(linkPreference, timeout return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLinkPreference", reflect.TypeOf((*MockManagement)(nil).SetLinkPreference), linkPreference, timeout) } +// UpdateWiFiSettings mocks base method. +func (m *MockManagement) UpdateWiFiSettings(wifiEndpointSettings wifi.WiFiEndpointSettingsRequest, ieee8021xSettings models.IEEE8021xSettings, clientCredential, caCredential string) (wifiportconfiguration.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWiFiSettings", wifiEndpointSettings, ieee8021xSettings, clientCredential, caCredential) + ret0, _ := ret[0].(wifiportconfiguration.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateWiFiSettings indicates an expected call of UpdateWiFiSettings. +func (mr *MockManagementMockRecorder) UpdateWiFiSettings(wifiEndpointSettings, ieee8021xSettings, clientCredential, caCredential any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWiFiSettings", reflect.TypeOf((*MockManagement)(nil).UpdateWiFiSettings), wifiEndpointSettings, ieee8021xSettings, clientCredential, caCredential) +} + // WiFiRequestStateChange mocks base method. func (m *MockManagement) WiFiRequestStateChange(requestedState wifi.RequestedState) error { m.ctrl.T.Helper() diff --git a/internal/mocks/wsv1_mocks.go b/internal/mocks/wsv1_mocks.go index ae71e3438..437695a01 100644 --- a/internal/mocks/wsv1_mocks.go +++ b/internal/mocks/wsv1_mocks.go @@ -16,6 +16,7 @@ import ( dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" v2 "github.com/device-management-toolkit/console/internal/entity/dto/v2" + config "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" power "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" wifi "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" gin "github.com/gin-gonic/gin" @@ -139,6 +140,20 @@ func (mr *MockFeatureMockRecorder) AddCertificate(c, guid, certInfo any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddCertificate", reflect.TypeOf((*MockFeature)(nil).AddCertificate), c, guid, certInfo) } +// AddWirelessProfile mocks base method. +func (m *MockFeature) AddWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddWirelessProfile", c, guid, profile) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddWirelessProfile indicates an expected call of AddWirelessProfile. +func (mr *MockFeatureMockRecorder) AddWirelessProfile(c, guid, profile any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddWirelessProfile", reflect.TypeOf((*MockFeature)(nil).AddWirelessProfile), c, guid, profile) +} + // CancelUserConsent mocks base method. func (m *MockFeature) CancelUserConsent(ctx context.Context, guid string) (dto.UserConsentMessage, error) { m.ctrl.T.Helper() @@ -211,6 +226,20 @@ func (mr *MockFeatureMockRecorder) DeleteCertificate(c, guid, instanceID any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificate", reflect.TypeOf((*MockFeature)(nil).DeleteCertificate), c, guid, instanceID) } +// DeleteWirelessProfile mocks base method. +func (m *MockFeature) DeleteWirelessProfile(c context.Context, guid, profileName string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteWirelessProfile", c, guid, profileName) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteWirelessProfile indicates an expected call of DeleteWirelessProfile. +func (mr *MockFeatureMockRecorder) DeleteWirelessProfile(c, guid, profileName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteWirelessProfile", reflect.TypeOf((*MockFeature)(nil).DeleteWirelessProfile), c, guid, profileName) +} + // Get mocks base method. func (m *MockFeature) Get(ctx context.Context, top, skip int, tenantID string) ([]dto.Device, error) { m.ctrl.T.Helper() @@ -558,6 +587,21 @@ func (mr *MockFeatureMockRecorder) GetVersion(ctx, guid any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockFeature)(nil).GetVersion), ctx, guid) } +// GetWirelessProfiles mocks base method. +func (m *MockFeature) GetWirelessProfiles(c context.Context, guid string) ([]dto.WirelessProfileResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWirelessProfiles", c, guid) + ret0, _ := ret[0].([]dto.WirelessProfileResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWirelessProfiles indicates an expected call of GetWirelessProfiles. +func (mr *MockFeatureMockRecorder) GetWirelessProfiles(c, guid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWirelessProfiles", reflect.TypeOf((*MockFeature)(nil).GetWirelessProfiles), c, guid) +} + // GetWirelessState mocks base method. func (m *MockFeature) GetWirelessState(c context.Context, guid string) (wifi.EnabledState, error) { m.ctrl.T.Helper() @@ -750,3 +794,17 @@ func (mr *MockFeatureMockRecorder) UpdateLastSeen(ctx, guid any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastSeen", reflect.TypeOf((*MockFeature)(nil).UpdateLastSeen), ctx, guid) } + +// UpdateWirelessProfile mocks base method. +func (m *MockFeature) UpdateWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWirelessProfile", c, guid, profile) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWirelessProfile indicates an expected call of UpdateWirelessProfile. +func (mr *MockFeatureMockRecorder) UpdateWirelessProfile(c, guid, profile any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWirelessProfile", reflect.TypeOf((*MockFeature)(nil).UpdateWirelessProfile), c, guid, profile) +} diff --git a/internal/usecase/devices/interfaces.go b/internal/usecase/devices/interfaces.go index 6b3931b6c..1e4239894 100644 --- a/internal/usecase/devices/interfaces.go +++ b/internal/usecase/devices/interfaces.go @@ -5,6 +5,7 @@ import ( "github.com/gorilla/websocket" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" @@ -83,6 +84,10 @@ type ( GetNetworkSettings(c context.Context, guid string) (dto.NetworkSettings, error) RequestWirelessStateChange(c context.Context, guid string, requestedState wifi.RequestedState) (wifi.RequestedState, error) GetWirelessState(c context.Context, guid string) (wifi.EnabledState, error) + GetWirelessProfiles(c context.Context, guid string) ([]dto.WirelessProfileResponse, error) + AddWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error + DeleteWirelessProfile(c context.Context, guid, profileName string) error + UpdateWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error GetCertificates(c context.Context, guid string) (dto.SecuritySettings, error) GetTLSSettingData(c context.Context, guid string) ([]dto.SettingDataResponse, error) GetDiskInfo(c context.Context, guid string) (dto.DiskInfo, error) diff --git a/internal/usecase/devices/wifiprofile.go b/internal/usecase/devices/wifiprofile.go new file mode 100644 index 000000000..ce80844b1 --- /dev/null +++ b/internal/usecase/devices/wifiprofile.go @@ -0,0 +1,634 @@ +package devices + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/concrete" + cimIEEE8021x "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/ieee8021x" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/models" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" + + "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/repoerrors" + "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" +) + +const ( + defaultWiFiEndpoint = "WiFi Endpoint 0" + instanceIDPrefixUserSettings = "Intel(r) AMT:WiFi Endpoint User Settings" + instanceIDFormatWiFiEndpoint = "Intel(r) AMT:WiFi Endpoint Settings %s" + instanceIDFormatIEEE8021x = "Intel(r) AMT:IEEE 802.1x Settings %s" + resourceCIMWiFiEndpointSettings = "CIM_WiFiEndpointSettings" + resourceCIMIEEE8021xSettings = "CIM_IEEE8021xSettings" + selectorNameInstanceID = "InstanceID" +) + +var ( + errInvalidAuthenticationMethod = errors.New("invalid authentication method") + errInvalidEncryptionMethod = errors.New("invalid encryption method") +) + +type IEEE8021xCertHandles struct { + ClientCertHandle string + RootCertHandle string +} + +type preparedWirelessProfile struct { + wifiRequest wifi.WiFiEndpointSettingsRequest + ieee8021xRequest models.IEEE8021xSettings + certHandles *IEEE8021xCertHandles +} + +func (uc *UseCase) GetWirelessProfiles(c context.Context, guid string) ([]dto.WirelessProfileResponse, error) { + device, err := uc.setupWirelessProfileManagement(c, guid) + if err != nil { + return nil, err + } + + profiles, err := getWirelessProfilesFromDevice(device) + if err != nil { + return nil, err + } + + return dto.NewWirelessProfileResponses(profiles), nil +} + +func (uc *UseCase) AddWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error { + device, err := uc.setupWirelessProfileManagement(c, guid) + if err != nil { + return err + } + + if _, err := device.GetWiFiPorts(); err != nil { + return err + } + + settings, err := device.GetWiFiSettings() + if err != nil { + return err + } + + if _, found := findWirelessSettingByProfileName(settings, profile.ProfileName); found { + return wirelessProfileAlreadyExists(profile.ProfileName) + } + + if _, found := findWirelessSettingByPriority(settings, profile.Priority); found { + return wirelessProfilePriorityAlreadyExists(profile.Priority) + } + + preparedProfile, needsPauseBeforeApply, err := prepareWirelessProfileForApply(device, profile) + if err != nil { + return err + } + + if needsPauseBeforeApply { + if err := waitForAMTCertificateHandling(c, time.Second); err != nil { + return err + } + } + + _, err = device.AddWiFiSettings( + preparedProfile.wifiRequest, + preparedProfile.ieee8021xRequest, + defaultWiFiEndpoint, + preparedProfile.certHandles.ClientCertHandle, + preparedProfile.certHandles.RootCertHandle, + ) + if err != nil { + return err + } + + return nil +} + +func (uc *UseCase) DeleteWirelessProfile(c context.Context, guid, profileName string) error { + device, err := uc.setupWirelessProfileManagement(c, guid) + if err != nil { + return err + } + + settings, err := device.GetWiFiSettings() + if err != nil { + return err + } + + setting, found := findWirelessSettingByProfileName(settings, profileName) + if !found { + return ErrNotFound + } + + if err := device.DeleteWiFiSetting(setting.InstanceID); err != nil { + return err + } + + return nil +} + +func (uc *UseCase) UpdateWirelessProfile(c context.Context, guid string, profile config.WirelessProfile) error { + device, err := uc.setupWirelessProfileManagement(c, guid) + if err != nil { + return err + } + + if _, err := device.GetWiFiPorts(); err != nil { + return err + } + + settings, err := device.GetWiFiSettings() + if err != nil { + return err + } + + current, found := findWirelessSettingByProfileName(settings, profile.ProfileName) + if !found { + return ErrNotFound + } + + if setting, found := findWirelessSettingByPriority(settings, profile.Priority); found { + if setting.InstanceID != current.InstanceID { + return wirelessProfilePriorityAlreadyExists(profile.Priority) + } + } + + preparedProfile, needsPauseBeforeApply, err := prepareWirelessProfileForApply(device, profile) + if err != nil { + return err + } + + preparedProfile.wifiRequest.InstanceID = current.InstanceID + + if needsPauseBeforeApply { + if err := waitForAMTCertificateHandling(c, time.Second); err != nil { + return err + } + } + + _, err = device.UpdateWiFiSettings( + preparedProfile.wifiRequest, + preparedProfile.ieee8021xRequest, + preparedProfile.certHandles.ClientCertHandle, + preparedProfile.certHandles.RootCertHandle, + ) + if err != nil { + return err + } + + return nil +} + +func (uc *UseCase) setupWirelessProfileManagement(c context.Context, guid string) (wsman.Management, error) { + item, err := uc.repo.GetByID(c, guid, "") + if err != nil { + return nil, err + } + + if item == nil || item.GUID == "" { + return nil, ErrNotFound + } + + device, err := uc.device.SetupWsmanClient(c, *item, false, true) + if err != nil { + return nil, err + } + + return device, nil +} + +func prepareWirelessProfileForApply(device wsman.Management, profile config.WirelessProfile) (preparedWirelessProfile, bool, error) { + wifiRequest, err := toWiFiEndpointSettingsRequest(profile) + if err != nil { + return preparedWirelessProfile{}, false, err + } + + prepared := preparedWirelessProfile{ + wifiRequest: wifiRequest, + ieee8021xRequest: models.IEEE8021xSettings{}, + certHandles: &IEEE8021xCertHandles{}, + } + + needsPauseBeforeApply := false + + if profile.IEEE8021x != nil { + prepared.ieee8021xRequest = toIEEE8021xSettingsRequest(profile) + + certHandles, pauseBeforeAdd, certErr := configureIEEE8021xCertificates( + device, + profile.IEEE8021x.PrivateKey, + profile.IEEE8021x.ClientCert, + profile.IEEE8021x.CACert, + ) + if certErr != nil { + return preparedWirelessProfile{}, false, certErr + } + + prepared.certHandles = certHandles + needsPauseBeforeApply = pauseBeforeAdd + } + + return prepared, needsPauseBeforeApply, nil +} + +func wirelessProfileAlreadyExists(profileName string) error { + notUniqueErr := repoerrors.NotUniqueError{Console: ErrDeviceUseCase} + + return notUniqueErr.Wrap(fmt.Sprintf("wireless profile %q already exists", profileName)) +} + +func wirelessProfilePriorityAlreadyExists(priority int) error { + notUniqueErr := repoerrors.NotUniqueError{Console: ErrDeviceUseCase} + + return notUniqueErr.Wrap(fmt.Sprintf("wireless profile with priority %d already exists", priority)) +} + +func findWirelessSettingByProfileName(settings []wifi.WiFiEndpointSettingsResponse, profileName string) (wifi.WiFiEndpointSettingsResponse, bool) { + for i := range settings { + setting := settings[i] + if setting.InstanceID == "" || isUserSettingsInstanceID(setting.InstanceID) { + continue + } + + if setting.ElementName == profileName { + return setting, true + } + } + + return wifi.WiFiEndpointSettingsResponse{}, false +} + +func findWirelessSettingByPriority(settings []wifi.WiFiEndpointSettingsResponse, priority int) (wifi.WiFiEndpointSettingsResponse, bool) { + for i := range settings { + setting := settings[i] + if setting.InstanceID == "" || isUserSettingsInstanceID(setting.InstanceID) { + continue + } + + if setting.Priority == priority { + return setting, true + } + } + + return wifi.WiFiEndpointSettingsResponse{}, false +} + +func getWirelessProfilesFromDevice(device wsman.Management) ([]config.WirelessProfile, error) { + settings, err := device.GetWiFiSettings() + if err != nil { + return nil, err + } + + ieee8021xResponse, err := device.GetCIMIEEE8021xSettings() + if err != nil { + return nil, err + } + + concreteDependencies, err := device.GetConcreteDependencies() + if err != nil { + return nil, err + } + + ieee8021xByID := indexIEEE8021xSettings(ieee8021xResponse.Body.PullResponse.IEEE8021xSettingsItems) + ieee8021xByProfileName := indexIEEE8021xSettingsByProfileName(ieee8021xResponse.Body.PullResponse.IEEE8021xSettingsItems) + associatedIEEE8021xByWiFiID := mapAssociatedIEEE8021xByWiFiID(concreteDependencies) + + profiles := make([]config.WirelessProfile, 0, len(settings)) + for i := range settings { + setting := settings[i] + if setting.InstanceID == "" || isUserSettingsInstanceID(setting.InstanceID) { + continue + } + + profile := wifiSettingToConfig(setting) + if ieee8021xSettings, found := findAssociatedIEEE8021xSettings(setting, associatedIEEE8021xByWiFiID, ieee8021xByID, ieee8021xByProfileName); found { + profile.IEEE8021x = ieee8021xSettingToConfig(ieee8021xSettings) + } + + profiles = append(profiles, profile) + } + + return profiles, nil +} + +func indexIEEE8021xSettings(settings []cimIEEE8021x.IEEE8021xSettingsResponse) map[string]cimIEEE8021x.IEEE8021xSettingsResponse { + indexed := make(map[string]cimIEEE8021x.IEEE8021xSettingsResponse, len(settings)) + for i := range settings { + setting := settings[i] + if setting.InstanceID == "" { + continue + } + + indexed[setting.InstanceID] = setting + } + + return indexed +} + +func indexIEEE8021xSettingsByProfileName(settings []cimIEEE8021x.IEEE8021xSettingsResponse) map[string]cimIEEE8021x.IEEE8021xSettingsResponse { + indexed := make(map[string]cimIEEE8021x.IEEE8021xSettingsResponse, len(settings)) + for i := range settings { + setting := settings[i] + if setting.ElementName == "" { + continue + } + + indexed[normalizeAssociationKey(setting.ElementName)] = setting + } + + return indexed +} + +func mapAssociatedIEEE8021xByWiFiID(dependencies []concrete.ConcreteDependency) map[string]string { + associated := map[string]string{} + + for i := range dependencies { + dependency := dependencies[i] + + wifiEndpointReference, ieee8021xReference, found := dependencyReferencesForWiFi8021x(dependency) + if !found { + continue + } + + wifiID, hasWiFiID := associationReferenceInstanceID(wifiEndpointReference) + + ieee8021xID, hasIEEE8021xID := associationReferenceInstanceID(ieee8021xReference) + if !hasWiFiID || !hasIEEE8021xID { + continue + } + + associated[wifiID] = ieee8021xID + } + + return associated +} + +func dependencyReferencesForWiFi8021x(dependency concrete.ConcreteDependency) (wifiEndpointReference, ieee8021xReference models.AssociationReference, found bool) { + antecedentURI := dependency.Antecedent.ReferenceParameters.ResourceURI + dependentURI := dependency.Dependent.ReferenceParameters.ResourceURI + + if isAssociationResource(antecedentURI, resourceCIMWiFiEndpointSettings) && isAssociationResource(dependentURI, resourceCIMIEEE8021xSettings) { + return dependency.Antecedent, dependency.Dependent, true + } + + if isAssociationResource(antecedentURI, resourceCIMIEEE8021xSettings) && isAssociationResource(dependentURI, resourceCIMWiFiEndpointSettings) { + return dependency.Dependent, dependency.Antecedent, true + } + + return wifiEndpointReference, ieee8021xReference, found +} + +func isAssociationResource(resourceURI, resourceName string) bool { + return strings.HasSuffix(strings.ToLower(resourceURI), strings.ToLower(resourceName)) +} + +func associationReferenceInstanceID(reference models.AssociationReference) (string, bool) { + selectors := reference.ReferenceParameters.SelectorSet.Selectors + for i := range selectors { + selector := selectors[i] + if !strings.EqualFold(selector.Name, selectorNameInstanceID) { + continue + } + + if selector.Text == "" { + return "", false + } + + return selector.Text, true + } + + return "", false +} + +func findAssociatedIEEE8021xSettings( + setting wifi.WiFiEndpointSettingsResponse, + associatedIEEE8021xByWiFiID map[string]string, + ieee8021xByID map[string]cimIEEE8021x.IEEE8021xSettingsResponse, + ieee8021xByProfileName map[string]cimIEEE8021x.IEEE8021xSettingsResponse, +) (cimIEEE8021x.IEEE8021xSettingsResponse, bool) { + if ieee8021xID, found := associatedIEEE8021xByWiFiID[setting.InstanceID]; found { + ieee8021xSettings, exists := ieee8021xByID[ieee8021xID] + if exists { + return ieee8021xSettings, true + } + } + + if setting.ElementName == "" { + return cimIEEE8021x.IEEE8021xSettingsResponse{}, false + } + + fallbackIEEE8021xID := fmt.Sprintf(instanceIDFormatIEEE8021x, setting.ElementName) + if ieee8021xSettings, found := ieee8021xByID[fallbackIEEE8021xID]; found { + return ieee8021xSettings, true + } + + ieee8021xSettings, found := ieee8021xByProfileName[normalizeAssociationKey(setting.ElementName)] + + return ieee8021xSettings, found +} + +func normalizeAssociationKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func isUserSettingsInstanceID(instanceID string) bool { + return strings.HasPrefix(instanceID, instanceIDPrefixUserSettings) +} + +func waitForAMTCertificateHandling(c context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + + defer func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + }() + + select { + case <-timer.C: + return nil + case <-c.Done(): + return c.Err() + } +} + +func toWiFiEndpointSettingsRequest(req config.WirelessProfile) (wifi.WiFiEndpointSettingsRequest, error) { + authMethod, ok := wifi.ParseAuthenticationMethod(req.AuthenticationMethod) + if !ok { + return wifi.WiFiEndpointSettingsRequest{}, fmt.Errorf("%w %q for profile %q", errInvalidAuthenticationMethod, req.AuthenticationMethod, req.ProfileName) + } + + encryptionMethod, ok := wifi.ParseEncryptionMethod(req.EncryptionMethod) + if !ok { + return wifi.WiFiEndpointSettingsRequest{}, fmt.Errorf("%w %q for profile %q", errInvalidEncryptionMethod, req.EncryptionMethod, req.ProfileName) + } + + return wifi.WiFiEndpointSettingsRequest{ + ElementName: req.ProfileName, + InstanceID: fmt.Sprintf(instanceIDFormatWiFiEndpoint, req.ProfileName), + AuthenticationMethod: authMethod, + EncryptionMethod: encryptionMethod, + SSID: req.SSID, + Priority: req.Priority, + PSKPassPhrase: req.Password, + }, nil +} + +func toIEEE8021xSettingsRequest(req config.WirelessProfile) models.IEEE8021xSettings { + if req.IEEE8021x == nil { + return models.IEEE8021xSettings{} + } + + return models.IEEE8021xSettings{ + ElementName: req.ProfileName, + InstanceID: fmt.Sprintf(instanceIDFormatIEEE8021x, req.ProfileName), + AuthenticationProtocol: models.AuthenticationProtocol(req.IEEE8021x.AuthenticationProtocol), + Username: req.IEEE8021x.Username, + Password: req.IEEE8021x.Password, + } +} + +func wifiSettingToConfig(setting wifi.WiFiEndpointSettingsResponse) config.WirelessProfile { + return config.WirelessProfile{ + ProfileName: setting.ElementName, + SSID: setting.SSID, + AuthenticationMethod: setting.AuthenticationMethod.String(), + EncryptionMethod: setting.EncryptionMethod.String(), + Priority: setting.Priority, + } +} + +func ieee8021xSettingToConfig(setting cimIEEE8021x.IEEE8021xSettingsResponse) *config.IEEE8021x { + return &config.IEEE8021x{ + Username: setting.Username, + Password: setting.Password, + AuthenticationProtocol: setting.AuthenticationProtocol, + } +} + +func configureIEEE8021xCertificates( + device wsman.Management, + privateKey, clientCert, caCert string, +) (*IEEE8021xCertHandles, bool, error) { + handles := &IEEE8021xCertHandles{} + + certs, err := device.GetCertificates() + if err != nil { + return nil, false, err + } + + addedCredentials := false + + if privateKey != "" { + var added bool + + _, certs, added, err = resolveOrAddCredentialHandle(certs, privateKey, findExistingPrivateKeyHandle, device.AddPrivateKey, device.GetCertificates) + if err != nil { + return nil, false, err + } + + addedCredentials = addedCredentials || added + } + + if clientCert != "" { + var added bool + + handles.ClientCertHandle, certs, added, err = resolveOrAddCredentialHandle(certs, clientCert, findExistingClientCertHandle, device.AddClientCert, device.GetCertificates) + if err != nil { + return nil, false, err + } + + addedCredentials = addedCredentials || added + } + + if caCert != "" { + var added bool + + handles.RootCertHandle, _, added, err = resolveOrAddCredentialHandle(certs, caCert, findExistingTrustedRootCertHandle, device.AddTrustedRootCert, device.GetCertificates) + if err != nil { + return nil, false, err + } + + addedCredentials = addedCredentials || added + } + + return handles, addedCredentials, nil +} + +type ( + credentialHandleFinder func(certs wsman.Certificates, credential string) (string, bool) + credentialHandleAdder func(credential string) (string, error) + certsRefresher func() (wsman.Certificates, error) +) + +func resolveOrAddCredentialHandle(certs wsman.Certificates, credential string, find credentialHandleFinder, add credentialHandleAdder, refresh certsRefresher) (handle string, updatedCerts wsman.Certificates, added bool, err error) { + updatedCerts = certs + + if credential == "" { + return "", updatedCerts, false, nil + } + + handle, found := find(updatedCerts, credential) + if found { + return handle, updatedCerts, false, nil + } + + handle, addErr := add(credential) + if addErr == nil { + return handle, updatedCerts, true, nil + } + + if !strings.Contains(strings.ToLower(addErr.Error()), "already exists") { + return "", updatedCerts, false, addErr + } + + updatedCerts, err = refresh() + if err != nil { + return "", updatedCerts, false, err + } + + handle, found = find(updatedCerts, credential) + if !found { + return "", updatedCerts, false, addErr + } + + return handle, updatedCerts, false, nil +} + +func findExistingPrivateKeyHandle(certs wsman.Certificates, privateKey string) (string, bool) { + for i := range certs.PublicPrivateKeyPairResponse.PublicPrivateKeyPairItems { + item := certs.PublicPrivateKeyPairResponse.PublicPrivateKeyPairItems[i] + if item.DERKey == privateKey { + return item.InstanceID, true + } + } + + return "", false +} + +func findExistingClientCertHandle(certs wsman.Certificates, clientCert string) (string, bool) { + for i := range certs.PublicKeyCertificateResponse.PublicKeyCertificateItems { + item := certs.PublicKeyCertificateResponse.PublicKeyCertificateItems[i] + if item.X509Certificate == clientCert && !item.TrustedRootCertificate { + return item.InstanceID, true + } + } + + return "", false +} + +func findExistingTrustedRootCertHandle(certs wsman.Certificates, caCert string) (string, bool) { + for i := range certs.PublicKeyCertificateResponse.PublicKeyCertificateItems { + item := certs.PublicKeyCertificateResponse.PublicKeyCertificateItems[i] + if item.X509Certificate == caCert && item.TrustedRootCertificate { + return item.InstanceID, true + } + } + + return "", false +} diff --git a/internal/usecase/devices/wifiprofile_private_test.go b/internal/usecase/devices/wifiprofile_private_test.go new file mode 100644 index 000000000..723bbe65b --- /dev/null +++ b/internal/usecase/devices/wifiprofile_private_test.go @@ -0,0 +1,725 @@ +package devices + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/publickey" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/publicprivate" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/concrete" + cimIEEE8021x "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/ieee8021x" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/models" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" + + "github.com/device-management-toolkit/console/internal/repoerrors" + "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" +) + +const testNewHandle = "new-handle" + +func wifiProfileTestCertificates() wsman.Certificates { + return wsman.Certificates{ + PublicPrivateKeyPairResponse: publicprivate.RefinedPullResponse{ + PublicPrivateKeyPairItems: []publicprivate.RefinedPublicPrivateKeyPair{{ + InstanceID: "pk-handle", + DERKey: "private-key", + }}, + }, + PublicKeyCertificateResponse: publickey.RefinedPullResponse{ + PublicKeyCertificateItems: []publickey.RefinedPublicKeyCertificateResponse{ + {InstanceID: "client-handle", X509Certificate: "client-cert", TrustedRootCertificate: false}, + {InstanceID: "root-handle", X509Certificate: "ca-cert", TrustedRootCertificate: true}, + }, + }, + } +} + +func TestWiFiProfileTransformers(t *testing.T) { + t.Parallel() + + t.Run("toWiFiEndpointSettingsRequest", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + profile config.WirelessProfile + res wifi.WiFiEndpointSettingsRequest + err string + }{ + { + name: "success", + profile: config.WirelessProfile{ + ProfileName: "Office", + SSID: "OfficeSSID", + Password: "P@ssword", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Priority: 5, + }, + res: wifi.WiFiEndpointSettingsRequest{ + ElementName: "Office", + InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Office", + AuthenticationMethod: wifi.AuthenticationMethodWPA2PSK, + EncryptionMethod: wifi.EncryptionMethodCCMP, + SSID: "OfficeSSID", + Priority: 5, + PSKPassPhrase: "P@ssword", + }, + }, + { + name: "invalid authentication method", + profile: config.WirelessProfile{ + ProfileName: "Office", + AuthenticationMethod: "INVALID", + EncryptionMethod: "CCMP", + }, + err: "invalid authentication method \"INVALID\" for profile \"Office\"", + }, + { + name: "invalid encryption method", + profile: config.WirelessProfile{ + ProfileName: "Office", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "INVALID", + }, + err: "invalid encryption method \"INVALID\" for profile \"Office\"", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, err := toWiFiEndpointSettingsRequest(tc.profile) + if tc.err != "" { + require.EqualError(t, err, tc.err) + require.Equal(t, wifi.WiFiEndpointSettingsRequest{}, res) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.res, res) + }) + } + }) + + t.Run("toIEEE8021xSettingsRequest", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + profile config.WirelessProfile + res models.IEEE8021xSettings + }{ + { + name: "empty", + profile: config.WirelessProfile{}, + res: models.IEEE8021xSettings{}, + }, + { + name: "success", + profile: config.WirelessProfile{ + ProfileName: "SecureProfile", + IEEE8021x: &config.IEEE8021x{ + AuthenticationProtocol: 2, + Username: "user", + Password: "secret", + }, + }, + res: models.IEEE8021xSettings{ + ElementName: "SecureProfile", + InstanceID: "Intel(r) AMT:IEEE 802.1x Settings SecureProfile", + AuthenticationProtocol: models.AuthenticationProtocol(2), + Username: "user", + Password: "secret", + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res := toIEEE8021xSettingsRequest(tc.profile) + require.Equal(t, tc.res, res) + }) + } + }) + + t.Run("wifiSettingToConfig", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setting wifi.WiFiEndpointSettingsResponse + res config.WirelessProfile + }{ + { + name: "success", + setting: wifi.WiFiEndpointSettingsResponse{ + ElementName: "ProfileB", + SSID: "SSID-B", + AuthenticationMethod: wifi.AuthenticationMethodWPAIEEE8021x, + EncryptionMethod: wifi.EncryptionMethodTKIP, + Priority: 3, + }, + res: config.WirelessProfile{ + ProfileName: "ProfileB", + SSID: "SSID-B", + AuthenticationMethod: "WPAIEEE8021x", + EncryptionMethod: "TKIP", + Priority: 3, + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res := wifiSettingToConfig(tc.setting) + require.Equal(t, tc.res, res) + }) + } + }) +} + +func TestWiFiProfileFindExistingHandles(t *testing.T) { + t.Parallel() + + certs := wifiProfileTestCertificates() + + tests := []struct { + name string + finder credentialHandleFinder + credential string + handle string + found bool + }{ + { + name: "private key found", + finder: findExistingPrivateKeyHandle, + credential: "private-key", + handle: "pk-handle", + found: true, + }, + { + name: "private key missing", + finder: findExistingPrivateKeyHandle, + credential: "missing-private", + handle: "", + found: false, + }, + { + name: "client cert found", + finder: findExistingClientCertHandle, + credential: "client-cert", + handle: "client-handle", + found: true, + }, + { + name: "client cert missing", + finder: findExistingClientCertHandle, + credential: "ca-cert", + handle: "", + found: false, + }, + { + name: "trusted root cert found", + finder: findExistingTrustedRootCertHandle, + credential: "ca-cert", + handle: "root-handle", + found: true, + }, + { + name: "trusted root cert missing", + finder: findExistingTrustedRootCertHandle, + credential: "client-cert", + handle: "", + found: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handle, found := tc.finder(certs, tc.credential) + require.Equal(t, tc.found, found) + require.Equal(t, tc.handle, handle) + }) + } +} + +func TestWiFiProfileResolveOrAddCredentialHandle(t *testing.T) { + t.Parallel() + + baseCerts := wifiProfileTestCertificates() + errGeneral := errors.New("general error") + errAlreadyExists := errors.New("ALREADY EXISTS") + + refreshed := wsman.Certificates{ + PublicKeyCertificateResponse: publickey.RefinedPullResponse{ + PublicKeyCertificateItems: []publickey.RefinedPublicKeyCertificateResponse{{ + InstanceID: testNewHandle, + X509Certificate: "new-client", + TrustedRootCertificate: false, + }}, + }, + } + + tests := []struct { + name string + credential string + certs wsman.Certificates + find credentialHandleFinder + add credentialHandleAdder + refresh certsRefresher + handle string + resCerts wsman.Certificates + added bool + err error + }{ + { + name: "empty credential", + credential: "", + certs: baseCerts, + find: findExistingClientCertHandle, + add: func(_ string) (string, error) { + return "", nil + }, + refresh: func() (wsman.Certificates, error) { + return wsman.Certificates{}, nil + }, + handle: "", + resCerts: baseCerts, + added: false, + err: nil, + }, + { + name: "existing handle is returned", + credential: "client-cert", + certs: baseCerts, + find: findExistingClientCertHandle, + add: func(_ string) (string, error) { + return "", errors.New("unexpected add call") + }, + refresh: func() (wsman.Certificates, error) { + return wsman.Certificates{}, errors.New("unexpected refresh call") + }, + handle: "client-handle", + resCerts: baseCerts, + added: false, + err: nil, + }, + { + name: "add succeeds", + credential: "new-client", + certs: baseCerts, + find: findExistingClientCertHandle, + add: func(_ string) (string, error) { + return testNewHandle, nil + }, + refresh: func() (wsman.Certificates, error) { + return wsman.Certificates{}, errors.New("unexpected refresh call") + }, + handle: testNewHandle, + resCerts: baseCerts, + added: true, + err: nil, + }, + { + name: "non already exists add error", + credential: "new-client", + certs: baseCerts, + find: findExistingClientCertHandle, + add: func(_ string) (string, error) { + return "", errGeneral + }, + refresh: func() (wsman.Certificates, error) { + return wsman.Certificates{}, nil + }, + handle: "", + resCerts: baseCerts, + added: false, + err: errGeneral, + }, + { + name: "already exists refresh resolves", + credential: "new-client", + certs: baseCerts, + find: findExistingClientCertHandle, + add: func(_ string) (string, error) { + return "", errAlreadyExists + }, + refresh: func() (wsman.Certificates, error) { + return refreshed, nil + }, + handle: testNewHandle, + resCerts: refreshed, + added: false, + err: nil, + }, + { + name: "already exists refresh fails", + credential: "new-client", + certs: baseCerts, + find: findExistingClientCertHandle, + add: func(_ string) (string, error) { + return "", errAlreadyExists + }, + refresh: func() (wsman.Certificates, error) { + return wsman.Certificates{}, errGeneral + }, + handle: "", + resCerts: wsman.Certificates{}, + added: false, + err: errGeneral, + }, + { + name: "already exists refresh missing handle", + credential: "new-client", + certs: baseCerts, + find: findExistingClientCertHandle, + add: func(_ string) (string, error) { + return "", errAlreadyExists + }, + refresh: func() (wsman.Certificates, error) { + return baseCerts, nil + }, + handle: "", + resCerts: baseCerts, + added: false, + err: errAlreadyExists, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handle, certs, added, err := resolveOrAddCredentialHandle(tc.certs, tc.credential, tc.find, tc.add, tc.refresh) + require.Equal(t, tc.handle, handle) + require.Equal(t, tc.resCerts, certs) + require.Equal(t, tc.added, added) + + if tc.err != nil { + require.IsType(t, tc.err, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestFindWirelessSettingByProfileName(t *testing.T) { + t.Parallel() + + settings := []wifi.WiFiEndpointSettingsResponse{ + {InstanceID: "", ElementName: "ignore-empty"}, + {InstanceID: instanceIDPrefixUserSettings + " Profile", ElementName: "ignored-user-setting"}, + {InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Home", ElementName: "Home"}, + } + + res, found := findWirelessSettingByProfileName(settings, "Home") + require.True(t, found) + require.Equal(t, "Intel(r) AMT:WiFi Endpoint Settings Home", res.InstanceID) + + _, found = findWirelessSettingByProfileName(settings, "home") + require.False(t, found) + + _, found = findWirelessSettingByProfileName(settings, "office") + require.False(t, found) +} + +func TestFindWirelessSettingByPriority(t *testing.T) { + t.Parallel() + + settings := []wifi.WiFiEndpointSettingsResponse{ + {InstanceID: "", ElementName: "ignore-empty", Priority: 1}, + {InstanceID: instanceIDPrefixUserSettings + " Profile", ElementName: "ignored-user-setting", Priority: 1}, + {InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Home", ElementName: "Home", Priority: 1}, + } + + res, found := findWirelessSettingByPriority(settings, 1) + require.True(t, found) + require.Equal(t, "Intel(r) AMT:WiFi Endpoint Settings Home", res.InstanceID) + + _, found = findWirelessSettingByPriority(settings, 2) + require.False(t, found) +} + +func TestWirelessProfileAlreadyExists(t *testing.T) { + t.Parallel() + + err := wirelessProfileAlreadyExists("Home") + require.Error(t, err) + require.IsType(t, repoerrors.NotUniqueError{}, err) +} + +func TestWirelessProfilePriorityAlreadyExists(t *testing.T) { + t.Parallel() + + err := wirelessProfilePriorityAlreadyExists(1) + require.Error(t, err) + require.IsType(t, repoerrors.NotUniqueError{}, err) +} + +func TestWiFiProfileIndexIEEE8021xSettings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + settings []cimIEEE8021x.IEEE8021xSettingsResponse + expectedNames map[string]string + }{ + { + name: "skips empty instance id", + settings: []cimIEEE8021x.IEEE8021xSettingsResponse{ + {InstanceID: "", ElementName: "skip"}, + {InstanceID: "id-1", ElementName: "keep"}, + }, + expectedNames: map[string]string{"id-1": "keep"}, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + indexed := indexIEEE8021xSettings(tc.settings) + require.Len(t, indexed, len(tc.expectedNames)) + + for id, expectedName := range tc.expectedNames { + require.Equal(t, expectedName, indexed[id].ElementName) + } + }) + } +} + +func TestWiFiProfileDependencyReferencesForWiFi8021x(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dependency concrete.ConcreteDependency + expectedFound bool + expectedWiFiURI string + expectedIEEEURI string + }{ + { + name: "forward mapping", + dependency: concrete.ConcreteDependency{ + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings"}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings"}}, + }, + expectedFound: true, + expectedWiFiURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings", + expectedIEEEURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings", + }, + { + name: "reverse mapping", + dependency: concrete.ConcreteDependency{ + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings"}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings"}}, + }, + expectedFound: true, + expectedWiFiURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings", + expectedIEEEURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings", + }, + { + name: "no match", + dependency: concrete.ConcreteDependency{ + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ComputerSystem"}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings"}}, + }, + expectedFound: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + wifiRef, ieeeRef, found := dependencyReferencesForWiFi8021x(tc.dependency) + require.Equal(t, tc.expectedFound, found) + + if !tc.expectedFound { + return + } + + require.Equal(t, tc.expectedWiFiURI, wifiRef.ReferenceParameters.ResourceURI) + require.Equal(t, tc.expectedIEEEURI, ieeeRef.ReferenceParameters.ResourceURI) + }) + } +} + +func TestWiFiProfileAssociationReferenceInstanceID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reference models.AssociationReference + expected string + found bool + }{ + { + name: "found", + reference: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "id-1"}}}}}, + expected: "id-1", + found: true, + }, + { + name: "empty instance id", + reference: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: ""}}}}}, + expected: "", + found: false, + }, + { + name: "missing selector", + reference: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "Name", Text: "x"}}}}}, + expected: "", + found: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + id, ok := associationReferenceInstanceID(tc.reference) + require.Equal(t, tc.found, ok) + require.Equal(t, tc.expected, id) + }) + } +} + +func TestWiFiProfileMapAssociatedIEEE8021xByWiFiID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dependencies []concrete.ConcreteDependency + expected map[string]string + }{ + { + name: "skips incomplete references", + dependencies: []concrete.ConcreteDependency{ + { + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "wifi-1"}}}}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "ieee-1"}}}}}, + }, + { + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "ieee-2"}}}}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "wifi-2"}}}}}, + }, + { + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: ""}}}}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "ieee-skip"}}}}}, + }, + { + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ComputerSystem"}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings"}}, + }, + }, + expected: map[string]string{ + "wifi-1": "ieee-1", + "wifi-2": "ieee-2", + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mapped := mapAssociatedIEEE8021xByWiFiID(tc.dependencies) + require.Equal(t, tc.expected, mapped) + }) + } +} + +func TestWiFiProfileFindAssociatedIEEE8021xSettings(t *testing.T) { + t.Parallel() + + ieeeByID := map[string]cimIEEE8021x.IEEE8021xSettingsResponse{ + "assoc-id": {InstanceID: "assoc-id", Username: "assoc-user"}, + "Intel(r) AMT:IEEE 802.1x Settings CorpFallback": {InstanceID: "Intel(r) AMT:IEEE 802.1x Settings CorpFallback", Username: "fallback-user"}, + } + ieeeByName := map[string]cimIEEE8021x.IEEE8021xSettingsResponse{ + "corpname": {ElementName: "CorpName", Username: "name-user"}, + } + + tests := []struct { + name string + setting wifi.WiFiEndpointSettingsResponse + associatedMap map[string]string + byID map[string]cimIEEE8021x.IEEE8021xSettingsResponse + byName map[string]cimIEEE8021x.IEEE8021xSettingsResponse + expectedUser string + found bool + }{ + { + name: "direct association match", + setting: wifi.WiFiEndpointSettingsResponse{InstanceID: "wifi-1", ElementName: "Ignored"}, + associatedMap: map[string]string{"wifi-1": "assoc-id"}, + byID: ieeeByID, + byName: ieeeByName, + expectedUser: "assoc-user", + found: true, + }, + { + name: "fallback by generated instance id", + setting: wifi.WiFiEndpointSettingsResponse{ElementName: "CorpFallback"}, + associatedMap: map[string]string{}, + byID: ieeeByID, + byName: ieeeByName, + expectedUser: "fallback-user", + found: true, + }, + { + name: "fallback by normalized profile name", + setting: wifi.WiFiEndpointSettingsResponse{ElementName: " CorpName "}, + associatedMap: map[string]string{}, + byID: map[string]cimIEEE8021x.IEEE8021xSettingsResponse{}, + byName: ieeeByName, + expectedUser: "name-user", + found: true, + }, + { + name: "no element name means not found", + setting: wifi.WiFiEndpointSettingsResponse{ElementName: ""}, + associatedMap: map[string]string{}, + byID: map[string]cimIEEE8021x.IEEE8021xSettingsResponse{}, + byName: map[string]cimIEEE8021x.IEEE8021xSettingsResponse{}, + expectedUser: "", + found: false, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res, found := findAssociatedIEEE8021xSettings(tc.setting, tc.associatedMap, tc.byID, tc.byName) + require.Equal(t, tc.found, found) + require.Equal(t, tc.expectedUser, res.Username) + }) + } +} diff --git a/internal/usecase/devices/wifiprofile_test.go b/internal/usecase/devices/wifiprofile_test.go new file mode 100644 index 000000000..0eac17891 --- /dev/null +++ b/internal/usecase/devices/wifiprofile_test.go @@ -0,0 +1,1159 @@ +package devices_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/wifiportconfiguration" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/concrete" + cimIEEE8021x "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/ieee8021x" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/models" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" + + "github.com/device-management-toolkit/console/internal/entity" + dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" + "github.com/device-management-toolkit/console/internal/mocks" + "github.com/device-management-toolkit/console/internal/repoerrors" + "github.com/device-management-toolkit/console/internal/usecase/devices" + "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" + "github.com/device-management-toolkit/console/pkg/logger" +) + +const ( + testWiFiEndpoint = "WiFi Endpoint 0" + testUserSettingsInstanceIDPrefix = "Intel(r) AMT:WiFi Endpoint User Settings" +) + +func initWiFiProfileTest(t *testing.T) (*devices.UseCase, *mocks.MockWSMAN, *mocks.MockManagement, *mocks.MockDeviceManagementRepository) { + t.Helper() + + mockCtl := gomock.NewController(t) + + repo := mocks.NewMockDeviceManagementRepository(mockCtl) + wsmanMock := mocks.NewMockWSMAN(mockCtl) + wsmanMock.EXPECT().Worker().Return().AnyTimes() + + management := mocks.NewMockManagement(mockCtl) + log := logger.New("error") + u := devices.New(repo, wsmanMock, mocks.NewMockRedirection(mockCtl), log, mocks.MockCrypto{}) + + return u, wsmanMock, management, repo +} + +func expectedWiFiRequest(profile config.WirelessProfile) wifi.WiFiEndpointSettingsRequest { + authMethod, ok := wifi.ParseAuthenticationMethod(profile.AuthenticationMethod) + if !ok { + panic(fmt.Sprintf("invalid authentication method in test profile: %q", profile.AuthenticationMethod)) + } + + encryptionMethod, ok := wifi.ParseEncryptionMethod(profile.EncryptionMethod) + if !ok { + panic(fmt.Sprintf("invalid encryption method in test profile: %q", profile.EncryptionMethod)) + } + + return wifi.WiFiEndpointSettingsRequest{ + ElementName: profile.ProfileName, + InstanceID: fmt.Sprintf("Intel(r) AMT:WiFi Endpoint Settings %s", profile.ProfileName), + AuthenticationMethod: authMethod, + EncryptionMethod: encryptionMethod, + SSID: profile.SSID, + Priority: profile.Priority, + PSKPassPhrase: profile.Password, + } +} + +// expectWiFiPortPresent registers the WiFi port lookup performed by the +// AddWirelessProfile/UpdateWirelessProfile guard, returning a single WiFi port +// so the operation proceeds. +func expectWiFiPortPresent(man2 *mocks.MockManagement) { + man2.EXPECT().GetWiFiPorts().Return([]wifi.WiFiPort{{}}, nil) +} + +// expectWiFiPortAbsent registers the WiFi port lookup performed by the guard, +// returning ErrNoWiFiPort so the operation fails. +func expectWiFiPortAbsent(man2 *mocks.MockManagement) { + man2.EXPECT().GetWiFiPorts().Return(nil, wsman.ErrNoWiFiPort) +} + +func TestGetWirelessProfiles(t *testing.T) { + t.Parallel() + + device := &entity.Device{GUID: "device-guid-123"} + tests := []struct { + name string + manMock func(*mocks.MockWSMAN, *mocks.MockManagement) + repoMock func(*mocks.MockDeviceManagementRepository) + res []dto.WirelessProfileResponse + err error + }{ + { + name: "success filters endpoint user settings by instance id", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{ + {ElementName: "ignored-user-setting", InstanceID: testUserSettingsInstanceIDPrefix + " Profile"}, + { + ElementName: "Corp", + InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Corp", + SSID: "CorpSSID", + AuthenticationMethod: wifi.AuthenticationMethodWPA2PSK, + EncryptionMethod: wifi.EncryptionMethodCCMP, + Priority: 2, + }, + }, nil), + man2.EXPECT().GetCIMIEEE8021xSettings().Return(cimIEEE8021x.Response{}, nil), + man2.EXPECT().GetConcreteDependencies().Return([]concrete.ConcreteDependency{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + res: []dto.WirelessProfileResponse{{ + ProfileName: "Corp", + SSID: "CorpSSID", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + Priority: 2, + }}, + err: nil, + }, + { + name: "success maps associated ieee8021x", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{ + { + ElementName: "Corp", + InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Corp", + SSID: "CorpSSID", + AuthenticationMethod: wifi.AuthenticationMethodWPA2IEEE8021x, + EncryptionMethod: wifi.EncryptionMethodCCMP, + Priority: 1, + }, + }, nil), + man2.EXPECT().GetCIMIEEE8021xSettings().Return(cimIEEE8021x.Response{Body: cimIEEE8021x.Body{PullResponse: cimIEEE8021x.PullResponse{IEEE8021xSettingsItems: []cimIEEE8021x.IEEE8021xSettingsResponse{{ + InstanceID: "Intel(r) AMT:IEEE 802.1x Settings Corp", + AuthenticationProtocol: 2, + Username: "corp-user", + Password: "corp-pass", + }}}}}, nil), + man2.EXPECT().GetConcreteDependencies().Return([]concrete.ConcreteDependency{{ + Antecedent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_WiFiEndpointSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "Intel(r) AMT:WiFi Endpoint Settings Corp"}}}}}, + Dependent: models.AssociationReference{ReferenceParameters: models.ReferenceParametersNoNamespace{ResourceURI: "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_IEEE8021xSettings", SelectorSet: models.SelectorNoNamespace{Selectors: []models.SelectorResponse{{Name: "InstanceID", Text: "Intel(r) AMT:IEEE 802.1x Settings Corp"}}}}}, + }}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + res: []dto.WirelessProfileResponse{{ + ProfileName: "Corp", + SSID: "CorpSSID", + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + Priority: 1, + IEEE8021x: &dto.WirelessIEEE8021xResponse{ + AuthenticationProtocol: 2, + Username: "corp-user", + }, + }}, + err: nil, + }, + { + name: "success maps ieee8021x by profile name fallback", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{ + { + ElementName: "CorpEAP2", + InstanceID: "Intel(r) AMT:WiFi Endpoint Settings CorpEAP2", + SSID: "CorpNet2", + AuthenticationMethod: wifi.AuthenticationMethodWPA2IEEE8021x, + EncryptionMethod: wifi.EncryptionMethodCCMP, + Priority: 4, + }, + }, nil), + man2.EXPECT().GetCIMIEEE8021xSettings().Return(cimIEEE8021x.Response{Body: cimIEEE8021x.Body{PullResponse: cimIEEE8021x.PullResponse{IEEE8021xSettingsItems: []cimIEEE8021x.IEEE8021xSettingsResponse{{ + ElementName: "CorpEAP2", + InstanceID: "Intel(r) AMT:IEEE 802.1x Settings CorpEAP2", + AuthenticationProtocol: 2, + Username: "corp-user", + Password: "corp-pass", + }}}}}, nil), + man2.EXPECT().GetConcreteDependencies().Return([]concrete.ConcreteDependency{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + res: []dto.WirelessProfileResponse{{ + ProfileName: "CorpEAP2", + SSID: "CorpNet2", + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + Priority: 4, + IEEE8021x: &dto.WirelessIEEE8021xResponse{ + AuthenticationProtocol: 2, + Username: "corp-user", + }, + }}, + err: nil, + }, + { + name: "GetByID fails", + manMock: nil, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(nil, ErrGeneral) + }, + res: nil, + err: devices.ErrGeneral, + }, + { + name: "device not found", + manMock: nil, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(nil, nil) + }, + res: nil, + err: devices.ErrNotFound, + }, + { + name: "device GUID empty", + manMock: nil, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(&entity.Device{}, nil) + }, + res: nil, + err: devices.ErrNotFound, + }, + { + name: "SetupWsmanClient fails", + 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: nil, + err: devices.ErrGeneral, + }, + { + name: "GetWiFiSettings fails", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + man2.EXPECT().GetWiFiSettings().Return(nil, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + res: nil, + err: devices.ErrGeneral, + }, + { + name: "GetCIMIEEE8021xSettings fails", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCIMIEEE8021xSettings().Return(cimIEEE8021x.Response{}, ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + res: nil, + err: devices.ErrGeneral, + }, + { + name: "GetConcreteDependencies fails", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCIMIEEE8021xSettings().Return(cimIEEE8021x.Response{}, nil), + man2.EXPECT().GetConcreteDependencies().Return(nil, ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + res: nil, + err: devices.ErrGeneral, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initWiFiProfileTest(t) + + if tc.manMock != nil { + tc.manMock(wsmanMock, management) + } + + if tc.repoMock != nil { + tc.repoMock(repo) + } + + res, err := useCase.GetWirelessProfiles(context.Background(), device.GUID) + require.Equal(t, tc.res, res) + + if tc.err != nil { + require.IsType(t, tc.err, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestAddWirelessProfile(t *testing.T) { //nolint:gocognit // table-driven coverage for create flow branches + t.Parallel() + + device := &entity.Device{GUID: "device-guid-123"} + profile := config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID", + Priority: 1, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + } + + tests := []struct { + name string + ctx context.Context + profile config.WirelessProfile + manMock func(*mocks.MockWSMAN, *mocks.MockManagement) + portMock func(*mocks.MockManagement) + repoMock func(*mocks.MockDeviceManagementRepository) + err error + errString bool + }{ + { + name: "success", + profile: profile, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().AddWiFiSettings(expectedWiFiRequest(profile), models.IEEE8021xSettings{}, testWiFiEndpoint, "", "").Return(wifiportconfiguration.Response{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + }, + { + name: "duplicate profile name", + profile: profile, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{{InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Home", ElementName: "Home"}}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: repoerrors.NotUniqueError{}, + }, + { + name: "duplicate profile priority", + profile: profile, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{{ + InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Office", + ElementName: "Office", + Priority: 1, + }}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: repoerrors.NotUniqueError{}, + }, + { + name: "setup fails", + profile: profile, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(nil, ErrGeneral) + }, + err: devices.ErrGeneral, + }, + { + name: "read wifi settings fails", + profile: profile, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + man2.EXPECT().GetWiFiSettings().Return(nil, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrGeneral, + }, + { + name: "add wifi settings fails", + profile: profile, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().AddWiFiSettings(expectedWiFiRequest(profile), models.IEEE8021xSettings{}, testWiFiEndpoint, "", "").Return(wifiportconfiguration.Response{}, ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrGeneral, + }, + { + name: "invalid authentication method", + profile: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID", + Priority: 1, + Password: "password", + AuthenticationMethod: "INVALID", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: fmt.Errorf("invalid authentication method %q for profile %q", "INVALID", "Home"), + errString: true, + }, + { + name: "canceled context returns before apply delay completes", + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + profile: config.WirelessProfile{ + ProfileName: "Corp", + SSID: "CorpSSID", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + PrivateKey: "new-private", + }, + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, nil), + man2.EXPECT().AddPrivateKey("new-private").Return("private-handle", nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(gomock.Any(), device.GUID, "").Return(device, nil) + }, + err: context.Canceled, + }, + { + name: "no wifi port returns ErrNoWiFiPort", + profile: profile, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + }, + portMock: expectWiFiPortAbsent, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: wsman.ErrNoWiFiPort, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initWiFiProfileTest(t) + + if tc.manMock != nil { + tc.manMock(wsmanMock, management) + } + + if tc.portMock != nil { + tc.portMock(management) + } else if tc.manMock != nil { + expectWiFiPortPresent(management) + } + + if tc.repoMock != nil { + tc.repoMock(repo) + } + + ctx := tc.ctx + if ctx == nil { + ctx = context.Background() + } + + err := useCase.AddWirelessProfile(ctx, device.GUID, tc.profile) + if tc.err != nil { + require.Error(t, err) + + if tc.errString { + require.Equal(t, tc.err.Error(), err.Error()) + + return + } + + require.IsType(t, tc.err, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestDeleteWirelessProfile(t *testing.T) { + t.Parallel() + + device := &entity.Device{GUID: "device-guid-123"} + + tests := []struct { + name string + manMock func(*mocks.MockWSMAN, *mocks.MockManagement) + repoMock func(*mocks.MockDeviceManagementRepository) + err error + }{ + { + name: "success", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{{InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Home", ElementName: "Home"}}, nil), + man2.EXPECT().DeleteWiFiSetting("Intel(r) AMT:WiFi Endpoint Settings Home").Return(nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + }, + { + name: "profile not found", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrNotFound, + }, + { + name: "read wifi settings fails", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + man2.EXPECT().GetWiFiSettings().Return(nil, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrGeneral, + }, + { + name: "delete fails", + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{{InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Home", ElementName: "Home"}}, nil), + man2.EXPECT().DeleteWiFiSetting("Intel(r) AMT:WiFi Endpoint Settings Home").Return(ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrGeneral, + }, + { + name: "setup fails", + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(nil, ErrGeneral) + }, + err: devices.ErrGeneral, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initWiFiProfileTest(t) + + if tc.manMock != nil { + tc.manMock(wsmanMock, management) + } + + if tc.repoMock != nil { + tc.repoMock(repo) + } + + err := useCase.DeleteWirelessProfile(context.Background(), device.GUID, "Home") + if tc.err != nil { + require.Error(t, err) + require.IsType(t, tc.err, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestUpdateWirelessProfile(t *testing.T) { //nolint:gocognit // table-driven coverage for update flow branches + t.Parallel() + + device := &entity.Device{GUID: "device-guid-123"} + baseSettings := []wifi.WiFiEndpointSettingsResponse{{InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Home", ElementName: "Home", Priority: 1}} + + tests := []struct { + name string + ctx context.Context + profileName string + request config.WirelessProfile + manMock func(*mocks.MockWSMAN, *mocks.MockManagement) + portMock func(*mocks.MockManagement) + repoMock func(*mocks.MockDeviceManagementRepository) + err error + errString bool + }{ + { + name: "success", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + req := config.WirelessProfile{ProfileName: "Home", SSID: "HomeSSID2", Priority: 2, Password: "password", AuthenticationMethod: "WPA2PSK", EncryptionMethod: "CCMP"} + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return(baseSettings, nil), + man2.EXPECT().UpdateWiFiSettings(expectedWiFiRequest(req), models.IEEE8021xSettings{}, "", "").Return(wifiportconfiguration.Response{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + }, + { + name: "success when matching priority belongs to current profile", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 1, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + req := config.WirelessProfile{ProfileName: "Home", SSID: "HomeSSID2", Priority: 1, Password: "password", AuthenticationMethod: "WPA2PSK", EncryptionMethod: "CCMP"} + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return(baseSettings, nil), + man2.EXPECT().UpdateWiFiSettings(expectedWiFiRequest(req), models.IEEE8021xSettings{}, "", "").Return(wifiportconfiguration.Response{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + }, + { + name: "profile name differs only by case", + profileName: "home", + request: config.WirelessProfile{ + ProfileName: "home", + SSID: "HomeSSID2", + Priority: 1, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return(baseSettings, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrNotFound, + }, + { + name: "success when context pause before update completes", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + PrivateKey: "new-private", + }, + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + req := config.WirelessProfile{ProfileName: "Home", SSID: "HomeSSID2", Priority: 2, AuthenticationMethod: "WPA2IEEE8021x", EncryptionMethod: "CCMP", IEEE8021x: &config.IEEE8021x{PrivateKey: "new-private"}} + + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return(baseSettings, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, nil), + man2.EXPECT().AddPrivateKey("new-private").Return("private-handle", nil), + man2.EXPECT().UpdateWiFiSettings(expectedWiFiRequest(req), models.IEEE8021xSettings{ElementName: "Home", InstanceID: "Intel(r) AMT:IEEE 802.1x Settings Home"}, "", "").Return(wifiportconfiguration.Response{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + }, + { + name: "duplicate profile priority", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + settings := []wifi.WiFiEndpointSettingsResponse{ + {InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Home", ElementName: "Home", Priority: 1}, + {InstanceID: "Intel(r) AMT:WiFi Endpoint Settings Office", ElementName: "Office", Priority: 2}, + } + + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return(settings, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: repoerrors.NotUniqueError{}, + }, + { + name: "setup fails", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(nil, ErrGeneral) + }, + err: devices.ErrGeneral, + }, + { + name: "profile not found", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrNotFound, + }, + { + name: "read wifi settings fails", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + man2.EXPECT().GetWiFiSettings().Return(nil, ErrGeneral) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrGeneral, + }, + { + name: "update wifi settings fails", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + req := config.WirelessProfile{ProfileName: "Home", SSID: "HomeSSID2", Priority: 2, Password: "password", AuthenticationMethod: "WPA2PSK", EncryptionMethod: "CCMP"} + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return(baseSettings, nil), + man2.EXPECT().UpdateWiFiSettings(expectedWiFiRequest(req), models.IEEE8021xSettings{}, "", "").Return(wifiportconfiguration.Response{}, ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: devices.ErrGeneral, + }, + { + name: "invalid authentication method", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "INVALID", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + man2.EXPECT().GetWiFiSettings().Return(baseSettings, nil) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: fmt.Errorf("invalid authentication method %q for profile %q", "INVALID", "Home"), + errString: true, + }, + { + name: "canceled context returns before update delay completes", + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + PrivateKey: "new-private", + }, + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return(baseSettings, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, nil), + man2.EXPECT().AddPrivateKey("new-private").Return("private-handle", nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(gomock.Any(), device.GUID, "").Return(device, nil) + }, + err: context.Canceled, + }, + { + name: "no wifi port returns ErrNoWiFiPort", + profileName: "Home", + request: config.WirelessProfile{ + ProfileName: "Home", + SSID: "HomeSSID2", + Priority: 2, + Password: "password", + AuthenticationMethod: "WPA2PSK", + EncryptionMethod: "CCMP", + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil) + }, + portMock: expectWiFiPortAbsent, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: wsman.ErrNoWiFiPort, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initWiFiProfileTest(t) + + if tc.manMock != nil { + tc.manMock(wsmanMock, management) + } + + if tc.portMock != nil { + tc.portMock(management) + } else if tc.manMock != nil { + expectWiFiPortPresent(management) + } + + if tc.repoMock != nil { + tc.repoMock(repo) + } + + ctx := tc.ctx + if ctx == nil { + ctx = context.Background() + } + + err := useCase.UpdateWirelessProfile(ctx, device.GUID, tc.request) + if tc.err != nil { + require.Error(t, err) + + if tc.errString { + require.Equal(t, tc.err.Error(), err.Error()) + + return + } + + require.IsType(t, tc.err, err) + + return + } + + require.NoError(t, err) + }) + } +} + +func TestAddWirelessProfileIEEE8021xCertificateHandling(t *testing.T) { + t.Parallel() + + device := &entity.Device{GUID: "device-guid-123"} + + successProfile := config.WirelessProfile{ + ProfileName: "Corp", + SSID: "CorpSSID", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + AuthenticationProtocol: 2, + Username: "corp-user", + Password: "corp-pass", + PrivateKey: "new-private", + ClientCert: "new-client", + CACert: "new-ca", + }, + } + + ieeeRequest := models.IEEE8021xSettings{ + ElementName: "Corp", + InstanceID: "Intel(r) AMT:IEEE 802.1x Settings Corp", + AuthenticationProtocol: 2, + Username: "corp-user", + Password: "corp-pass", + } + + tests := []struct { + name string + profile config.WirelessProfile + manMock func(*mocks.MockWSMAN, *mocks.MockManagement) + repoMock func(*mocks.MockDeviceManagementRepository) + err error + }{ + { + name: "success with all ieee8021x credentials", + profile: successProfile, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, nil), + man2.EXPECT().AddPrivateKey("new-private").Return("private-handle", nil), + man2.EXPECT().AddClientCert("new-client").Return("client-handle", nil), + man2.EXPECT().AddTrustedRootCert("new-ca").Return("root-handle", nil), + man2.EXPECT().AddWiFiSettings(expectedWiFiRequest(successProfile), ieeeRequest, testWiFiEndpoint, "client-handle", "root-handle").Return(wifiportconfiguration.Response{}, nil), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + }, + { + name: "configure ieee8021x certificates - get certificates fails", + profile: config.WirelessProfile{ + ProfileName: "Corp", + SSID: "CorpSSID", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + PrivateKey: "new-private", + }, + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: ErrGeneral, + }, + { + name: "configure ieee8021x certificates - add private key fails", + profile: config.WirelessProfile{ + ProfileName: "Corp", + SSID: "CorpSSID", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + PrivateKey: "new-private", + }, + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, nil), + man2.EXPECT().AddPrivateKey("new-private").Return("", ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: ErrGeneral, + }, + { + name: "configure ieee8021x certificates - add client cert fails", + profile: config.WirelessProfile{ + ProfileName: "Corp", + SSID: "CorpSSID", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + ClientCert: "new-client", + }, + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, nil), + man2.EXPECT().AddClientCert("new-client").Return("", ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: ErrGeneral, + }, + { + name: "configure ieee8021x certificates - add trusted root cert fails", + profile: config.WirelessProfile{ + ProfileName: "Corp", + SSID: "CorpSSID", + Priority: 1, + AuthenticationMethod: "WPA2IEEE8021x", + EncryptionMethod: "CCMP", + IEEE8021x: &config.IEEE8021x{ + CACert: "new-ca", + }, + }, + manMock: func(man *mocks.MockWSMAN, man2 *mocks.MockManagement) { + gomock.InOrder( + man.EXPECT().SetupWsmanClient(gomock.Any(), gomock.Any(), false, true).Return(man2, nil), + man2.EXPECT().GetWiFiSettings().Return([]wifi.WiFiEndpointSettingsResponse{}, nil), + man2.EXPECT().GetCertificates().Return(wsman.Certificates{}, nil), + man2.EXPECT().AddTrustedRootCert("new-ca").Return("", ErrGeneral), + ) + }, + repoMock: func(repo *mocks.MockDeviceManagementRepository) { + repo.EXPECT().GetByID(context.Background(), device.GUID, "").Return(device, nil) + }, + err: ErrGeneral, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + useCase, wsmanMock, management, repo := initWiFiProfileTest(t) + + if tc.manMock != nil { + tc.manMock(wsmanMock, management) + expectWiFiPortPresent(management) + } + + if tc.repoMock != nil { + tc.repoMock(repo) + } + + err := useCase.AddWirelessProfile(context.Background(), device.GUID, tc.profile) + if tc.err != nil { + require.Error(t, err) + require.IsType(t, tc.err, err) + + return + } + + require.NoError(t, err) + }) + } +} diff --git a/internal/usecase/devices/wifistate.go b/internal/usecase/devices/wifistate.go index 7588ca29d..b2a44ecbb 100644 --- a/internal/usecase/devices/wifistate.go +++ b/internal/usecase/devices/wifistate.go @@ -5,7 +5,7 @@ import ( "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" - wsman "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" + "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" ) func (uc *UseCase) RequestWirelessStateChange(c context.Context, guid string, requestedState wifi.RequestedState) (wifi.RequestedState, error) { diff --git a/internal/usecase/devices/wifistate_test.go b/internal/usecase/devices/wifistate_test.go index d1d45abe5..4fb75d615 100644 --- a/internal/usecase/devices/wifistate_test.go +++ b/internal/usecase/devices/wifistate_test.go @@ -6,14 +6,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - gomock "go.uber.org/mock/gomock" + "go.uber.org/mock/gomock" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/wifi" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/common" "github.com/device-management-toolkit/console/internal/entity" "github.com/device-management-toolkit/console/internal/mocks" - devices "github.com/device-management-toolkit/console/internal/usecase/devices" + "github.com/device-management-toolkit/console/internal/usecase/devices" "github.com/device-management-toolkit/console/internal/usecase/devices/wsman" "github.com/device-management-toolkit/console/pkg/logger" ) diff --git a/internal/usecase/devices/wsman/interfaces.go b/internal/usecase/devices/wsman/interfaces.go index f229f38c7..e320b4bcd 100644 --- a/internal/usecase/devices/wsman/interfaces.go +++ b/internal/usecase/devices/wsman/interfaces.go @@ -11,10 +11,13 @@ import ( "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/redirection" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/setupandconfiguration" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/tls" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/amt/wifiportconfiguration" cimBoot "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/boot" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/concrete" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/credential" + cimIEEE8021x "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/ieee8021x" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/kvm" + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/models" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/power" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/service" "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/wsman/cim/software" @@ -60,9 +63,16 @@ type Management interface { GetAuditLog(startIndex int) (auditlog.Response, error) GetEventLog(startIndex, maxReadRecords int) (messagelog.GetRecordsResponse, error) GetNetworkSettings() (NetworkResults, error) + GetWiFiSettings() ([]wifi.WiFiEndpointSettingsResponse, error) + GetCIMIEEE8021xSettings() (cimIEEE8021x.Response, error) + DeleteWiFiSetting(instanceID string) error + AddWiFiSettings(wifiEndpointSettings wifi.WiFiEndpointSettingsRequest, ieee8021xSettings models.IEEE8021xSettings, wifiEndpoint, clientCredential, caCredential string) (wifiportconfiguration.Response, error) + UpdateWiFiSettings(wifiEndpointSettings wifi.WiFiEndpointSettingsRequest, ieee8021xSettings models.IEEE8021xSettings, clientCredential, caCredential string) (wifiportconfiguration.Response, error) EnumerateWiFiPort() (wifi.Response, error) PullWiFiPort(enumerationContext string) (wifi.Response, error) + GetWiFiPorts() ([]wifi.WiFiPort, error) WiFiRequestStateChange(requestedState wifi.RequestedState) error + AddPrivateKey(privateKey string) (string, error) GetCertificates() (Certificates, error) GetTLSSettingData() ([]tls.SettingDataResponse, error) GetCredentialRelationships() (credential.Items, error) diff --git a/internal/usecase/devices/wsman/message.go b/internal/usecase/devices/wsman/message.go index 54dba7db7..5152601c2 100644 --- a/internal/usecase/devices/wsman/message.go +++ b/internal/usecase/devices/wsman/message.go @@ -958,6 +958,27 @@ func (c *ConnectionEntry) PullWiFiPort(enumerationContext string) (response wifi return c.WsmanMessages.CIM.WiFiPort.Pull(enumerationContext) } +// GetWiFiPorts enumerates and pulls the device's WiFi ports. It returns +// ErrNoWiFiPort when the device exposes no WiFi interface, so callers across +// WiFi-related flows get a consistent signal instead of a downstream AMT fault. +func (c *ConnectionEntry) GetWiFiPorts() ([]wifi.WiFiPort, error) { + enumerateResponse, err := c.EnumerateWiFiPort() + if err != nil { + return nil, err + } + + pullResponse, err := c.PullWiFiPort(enumerateResponse.Body.EnumerateResponse.EnumerationContext) + if err != nil { + return nil, err + } + + if len(pullResponse.Body.PullResponse.WiFiPortItems) == 0 { + return nil, ErrNoWiFiPort + } + + return pullResponse.Body.PullResponse.WiFiPortItems, nil +} + func (c *ConnectionEntry) PutWiFiPortConfigurationService(request wifiportconfiguration.WiFiPortConfigurationServiceRequest) (wifiportconfiguration.WiFiPortConfigurationServiceResponse, error) { // if local sync not enable, enable it // if response.Body.WiFiPortConfigurationService.LocalProfileSynchronizationEnabled == wifiportconfiguration.LocalSyncDisabled { @@ -996,6 +1017,10 @@ func (c *ConnectionEntry) AddWiFiSettings(wifiEndpointSettings wifi.WiFiEndpoint return c.WsmanMessages.AMT.WiFiPortConfigurationService.AddWiFiSettings(wifiEndpointSettings, ieee8021xSettings, wifiEndpoint, clientCredential, caCredential) } +func (c *ConnectionEntry) UpdateWiFiSettings(wifiEndpointSettings wifi.WiFiEndpointSettingsRequest, ieee8021xSettings models.IEEE8021xSettings, clientCredential, caCredential string) (response wifiportconfiguration.Response, err error) { + return c.WsmanMessages.AMT.WiFiPortConfigurationService.UpdateWiFiSettings(wifiEndpointSettings, ieee8021xSettings, clientCredential, caCredential) +} + func (c *ConnectionEntry) PUTTLSSettings(instanceID string, tlsSettingData tls.SettingDataRequest) (response tls.Response, err error) { return c.WsmanMessages.AMT.TLSSettingData.Put(instanceID, tlsSettingData) }