From dbefff77c781714378967f8f88ab22a36577a0bb Mon Sep 17 00:00:00 2001 From: sinchubhat Date: Tue, 19 May 2026 09:10:29 +0530 Subject: [PATCH 1/2] feat: store lmsInstalled in deviceInfo JSON column * Wire deviceInfo serialization/deserialization in dtoToEntity/entityToDTO * Merge isLMSAvailable from activation into deviceInfo.lmsInstalled * Add LMSInstalled field to DeviceInfo DTO struct Related to device-management-toolkit/rpc-go#1246 --- internal/entity/dto/v1/device.go | 15 +-- internal/usecase/devices/repo.go | 40 ++++++-- .../usecase/devices/transform_fuzz_test.go | 11 ++- internal/usecase/devices/usecase.go | 66 +++++++++---- .../usecase/devices/usecase_private_test.go | 93 +++++++++++++++++++ internal/usecase/sqldb/device_test.go | 3 +- 6 files changed, 196 insertions(+), 32 deletions(-) create mode 100644 internal/usecase/devices/usecase_private_test.go diff --git a/internal/entity/dto/v1/device.go b/internal/entity/dto/v1/device.go index fccfd0676..538d9bcc6 100644 --- a/internal/entity/dto/v1/device.go +++ b/internal/entity/dto/v1/device.go @@ -37,13 +37,14 @@ type Device struct { } type DeviceInfo struct { - FWVersion string `json:"fwVersion"` - FWBuild string `json:"fwBuild"` - FWSku string `json:"fwSku"` - CurrentMode string `json:"currentMode"` - Features string `json:"features"` - IPAddress string `json:"ipAddress"` - LastUpdated time.Time `json:"lastUpdated"` + FWVersion string `json:"fwVersion"` + FWBuild string `json:"fwBuild"` + FWSku string `json:"fwSku"` + CurrentMode string `json:"currentMode"` + Features string `json:"features"` + IPAddress string `json:"ipAddress"` + LastUpdated time.Time `json:"lastUpdated"` + LMSInstalled *bool `json:"lmsInstalled,omitempty"` } type Explorer struct { diff --git a/internal/usecase/devices/repo.go b/internal/usecase/devices/repo.go index 9fcc7ec34..28e067d52 100644 --- a/internal/usecase/devices/repo.go +++ b/internal/usecase/devices/repo.go @@ -41,7 +41,13 @@ func (uc *UseCase) Get(ctx context.Context, top, skip int, tenantID string) ([]d for i := range data { tmpEntity := data[i] // create a new variable to avoid memory aliasing - d1[i] = *uc.entityToDTO(&tmpEntity) + + d, err := uc.entityToDTO(&tmpEntity) + if err != nil { + return nil, err + } + + d1[i] = *d } return d1, nil @@ -58,7 +64,13 @@ func (uc *UseCase) GetByColumn(ctx context.Context, columnName, queryValue, tena for i := range data { tmpEntity := data[i] // create a new variable to avoid memory aliasing - d1[i] = *uc.entityToDTO(&tmpEntity) + + d, err := uc.entityToDTO(&tmpEntity) + if err != nil { + return nil, err + } + + d1[i] = *d } return d1, nil @@ -74,7 +86,10 @@ func (uc *UseCase) GetByID(ctx context.Context, guid, tenantID string, includeSe return nil, ErrNotFound } - d2 := uc.entityToDTO(data) + d2, err := uc.entityToDTO(data) + if err != nil { + return nil, err + } if includeSecrets { if err := uc.decryptSecrets(d2, data); err != nil { @@ -140,7 +155,13 @@ func (uc *UseCase) GetByTags(ctx context.Context, tags, method string, limit, of for i := range data { tmpEntity := data[i] // create a new variable to avoid memory aliasing - d1[i] = *uc.entityToDTO(&tmpEntity) + + d, err := uc.entityToDTO(&tmpEntity) + if err != nil { + return nil, err + } + + d1[i] = *d } return d1, nil @@ -209,7 +230,10 @@ func (uc *UseCase) Update(ctx context.Context, d *dto.Device, fields map[string] return nil, err } - d2 := uc.entityToDTO(updateDevice) + d2, err := uc.entityToDTO(updateDevice) + if err != nil { + return nil, err + } // invalidate connection cache uc.device.DestroyWsmanClient(*d2) @@ -237,7 +261,11 @@ func (uc *UseCase) Insert(ctx context.Context, d *dto.Device) (*dto.Device, erro return nil, err } - d2 := uc.entityToDTO(newDevice) + d2, err := uc.entityToDTO(newDevice) + if err != nil { + return nil, err + } + if newDevice.Tags == "" { d2.Tags = []string{} } diff --git a/internal/usecase/devices/transform_fuzz_test.go b/internal/usecase/devices/transform_fuzz_test.go index bca9ff933..b49c23c98 100644 --- a/internal/usecase/devices/transform_fuzz_test.go +++ b/internal/usecase/devices/transform_fuzz_test.go @@ -143,8 +143,15 @@ func verifyDTOToEntity(t *testing.T, uc *UseCase, guid, certHash string, buildDT func verifyEntityToDTO(t *testing.T, uc *UseCase, buildEntity func() *entity.Device) { t.Helper() - firstDTO := uc.entityToDTO(buildEntity()) - secondDTO := uc.entityToDTO(buildEntity()) + firstDTO, err := uc.entityToDTO(buildEntity()) + if err != nil { + t.Fatalf("entityToDTO first call: %v", err) + } + + secondDTO, err := uc.entityToDTO(buildEntity()) + if err != nil { + t.Fatalf("entityToDTO second call: %v", err) + } if !reflect.DeepEqual(firstDTO, secondDTO) { t.Fatalf("entityToDTO result mismatch") diff --git a/internal/usecase/devices/usecase.go b/internal/usecase/devices/usecase.go index a61fb361a..50eaad554 100644 --- a/internal/usecase/devices/usecase.go +++ b/internal/usecase/devices/usecase.go @@ -1,6 +1,7 @@ package devices import ( + "encoding/json" "strings" "sync" @@ -74,6 +75,11 @@ func (uc *UseCase) dtoToEntity(d *dto.Device) (*entity.Device, error) { tags := strings.Join(d.Tags, ",") + deviceInfo, err := marshalDeviceInfo(d.DeviceInfo) + if err != nil { + return nil, ErrDeviceUseCase.Wrap("dtoToEntity", "marshalDeviceInfo", err) + } + d1 := &entity.Device{ ConnectionStatus: d.ConnectionStatus, MPSInstance: d.MPSInstance, @@ -87,15 +93,13 @@ func (uc *UseCase) dtoToEntity(d *dto.Device) (*entity.Device, error) { LastConnected: d.LastConnected, LastSeen: d.LastSeen, LastDisconnected: d.LastDisconnected, - // DeviceInfo: d.DeviceInfo, - Username: d.Username, - Password: d.Password, - UseTLS: d.UseTLS, - AllowSelfSigned: d.AllowSelfSigned, + DeviceInfo: deviceInfo, + Username: d.Username, + Password: d.Password, + UseTLS: d.UseTLS, + AllowSelfSigned: d.AllowSelfSigned, } - var err error - d1.Password, err = uc.safeRequirements.Encrypt(d1.Password) if err != nil { return nil, ErrDeviceUseCase.Wrap("dtoToEntity", "failed to encrypt password", err) @@ -133,8 +137,7 @@ func (uc *UseCase) dtoToEntity(d *dto.Device) (*entity.Device, error) { } // Keys are lowercased to match encoding/json's case-insensitive unmarshal. -// guid and tenantId identify the record; deviceInfo doesn't round-trip through -// dtoToEntity/entityToDTO — all three are intentionally omitted. +// guid and tenantId identify the record and are intentionally omitted. var deviceFieldSetters = map[string]func(dst, src *dto.Device){ "connectionstatus": func(dst, src *dto.Device) { dst.ConnectionStatus = src.ConnectionStatus }, "mpsinstance": func(dst, src *dto.Device) { dst.MPSInstance = src.MPSInstance }, @@ -153,6 +156,7 @@ var deviceFieldSetters = map[string]func(dst, src *dto.Device){ "usetls": func(dst, src *dto.Device) { dst.UseTLS = src.UseTLS }, "allowselfsigned": func(dst, src *dto.Device) { dst.AllowSelfSigned = src.AllowSelfSigned }, "certhash": func(dst, src *dto.Device) { dst.CertHash = src.CertHash }, + "deviceinfo": func(dst, src *dto.Device) { dst.DeviceInfo = src.DeviceInfo }, } func mergeDeviceFields(dst, src *dto.Device, fields map[string]bool) { @@ -164,13 +168,18 @@ func mergeDeviceFields(dst, src *dto.Device, fields map[string]bool) { } // convert entity.Device to dto.Device. -func (uc *UseCase) entityToDTO(d *entity.Device) *dto.Device { +func (uc *UseCase) entityToDTO(d *entity.Device) (*dto.Device, error) { // convert comma separated string to []string var tags []string if d.Tags != "" { tags = strings.Split(d.Tags, ",") } + deviceInfo, err := unmarshalDeviceInfo(d.DeviceInfo, d.GUID) + if err != nil { + return nil, err + } + d1 := &dto.Device{ ConnectionStatus: d.ConnectionStatus, MPSInstance: d.MPSInstance, @@ -184,11 +193,10 @@ func (uc *UseCase) entityToDTO(d *entity.Device) *dto.Device { LastConnected: d.LastConnected, LastSeen: d.LastSeen, LastDisconnected: d.LastDisconnected, - // DeviceInfo: d.DeviceInfo, - Username: d.Username, - // Password: d.Password, - UseTLS: d.UseTLS, - AllowSelfSigned: d.AllowSelfSigned, + DeviceInfo: deviceInfo, + Username: d.Username, + UseTLS: d.UseTLS, + AllowSelfSigned: d.AllowSelfSigned, } if d.CertHash != nil { @@ -203,5 +211,31 @@ func (uc *UseCase) entityToDTO(d *entity.Device) *dto.Device { d1.MEBXPassword = *d.MEBXPassword } - return d1 + return d1, nil +} + +func marshalDeviceInfo(info *dto.DeviceInfo) (string, error) { + if info == nil { + return "", nil + } + + b, err := json.Marshal(info) + if err != nil { + return "", err + } + + return string(b), nil +} + +func unmarshalDeviceInfo(raw, guid string) (*dto.DeviceInfo, error) { + if raw == "" { + return nil, nil + } + + var info dto.DeviceInfo + if err := json.Unmarshal([]byte(raw), &info); err != nil { + return nil, ErrDeviceUseCase.Wrap("unmarshalDeviceInfo", "failed to unmarshal deviceInfo for device "+guid, err) + } + + return &info, nil } diff --git a/internal/usecase/devices/usecase_private_test.go b/internal/usecase/devices/usecase_private_test.go new file mode 100644 index 000000000..14359f51a --- /dev/null +++ b/internal/usecase/devices/usecase_private_test.go @@ -0,0 +1,93 @@ +package devices + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + + "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/pkg/logger" +) + +type stubCrypto struct{} + +func (stubCrypto) Encrypt(string) (string, error) { return "encrypted", nil } +func (stubCrypto) Decrypt(string) (string, error) { return "decrypted", nil } +func (stubCrypto) EncryptWithKey(string, string) (string, error) { return "encrypted", nil } +func (stubCrypto) GenerateKey() string { return "key" } +func (stubCrypto) ReadAndDecryptFile(string) (config.Configuration, error) { + return config.Configuration{}, nil +} + +func TestDtoToEntity_DeviceInfoSerialization(t *testing.T) { + t.Parallel() + + uc := &UseCase{log: logger.New("error"), safeRequirements: stubCrypto{}} + lms := true + + t.Run("returns empty DeviceInfo when nil", func(t *testing.T) { + t.Parallel() + + d := &dto.Device{GUID: "g1", TenantID: "t1", DeviceInfo: nil} + ent, err := uc.dtoToEntity(d) + require.NoError(t, err) + require.Empty(t, ent.DeviceInfo) + }) + + t.Run("serializes LMSInstalled", func(t *testing.T) { + t.Parallel() + + d := &dto.Device{GUID: "g1", TenantID: "t1", DeviceInfo: &dto.DeviceInfo{LMSInstalled: &lms}} + ent, err := uc.dtoToEntity(d) + require.NoError(t, err) + require.Contains(t, ent.DeviceInfo, `"lmsInstalled":true`) + }) + + t.Run("serializes DeviceInfo with multiple fields", func(t *testing.T) { + t.Parallel() + + d := &dto.Device{ + GUID: "g2", + TenantID: "t1", + DeviceInfo: &dto.DeviceInfo{FWVersion: "16.1.25", LMSInstalled: &lms}, + } + ent, err := uc.dtoToEntity(d) + require.NoError(t, err) + require.Contains(t, ent.DeviceInfo, `"lmsInstalled":true`) + require.Contains(t, ent.DeviceInfo, `"fwVersion":"16.1.25"`) + }) +} + +func TestEntityToDTO_DeviceInfoDeserialization(t *testing.T) { + t.Parallel() + + uc := &UseCase{log: logger.New("error"), safeRequirements: stubCrypto{}} + + ent := &entity.Device{ + GUID: "guid-1", + TenantID: "t-1", + DeviceInfo: `{"fwVersion":"16.1.25","lmsInstalled":true}`, + } + + d, err := uc.entityToDTO(ent) + require.NoError(t, err) + require.NotNil(t, d.DeviceInfo) + require.Equal(t, "16.1.25", d.DeviceInfo.FWVersion) + require.NotNil(t, d.DeviceInfo.LMSInstalled) + require.True(t, *d.DeviceInfo.LMSInstalled) +} + +func TestMergeDeviceFields_NewSetters(t *testing.T) { + t.Parallel() + + lms := true + info := &dto.DeviceInfo{FWVersion: "16.1.25", LMSInstalled: &lms} + src := &dto.Device{DeviceInfo: info} + dst := &dto.Device{} + + mergeDeviceFields(dst, src, map[string]bool{"deviceinfo": true}) + require.Equal(t, info, dst.DeviceInfo) +} diff --git a/internal/usecase/sqldb/device_test.go b/internal/usecase/sqldb/device_test.go index 9ad5c830f..67d7d806e 100644 --- a/internal/usecase/sqldb/device_test.go +++ b/internal/usecase/sqldb/device_test.go @@ -663,7 +663,8 @@ func TestDeviceRepo_Delete(t *testing.T) { mpspassword TEXT, mebxpassword TEXT, usetls BOOLEAN NOT NULL DEFAULT FALSE, - allowselfsigned BOOLEAN NOT NULL DEFAULT FALSE + allowselfsigned BOOLEAN NOT NULL DEFAULT FALSE, + certhash TEXT NOT NULL DEFAULT '' ); `) require.NoError(t, err) From 25d8666131e2fc7afdddcbb44fcb53b9a8f27b01 Mon Sep 17 00:00:00 2001 From: Mike Johanson Date: Fri, 29 May 2026 15:42:07 -0700 Subject: [PATCH 2/2] refactor: reuse existing mock --- internal/mocks/crypto/crypto.go | 46 +++++++++++++++++++ internal/mocks/crypto_mocks.go | 42 +++-------------- .../usecase/devices/usecase_private_test.go | 17 ++----- 3 files changed, 55 insertions(+), 50 deletions(-) create mode 100644 internal/mocks/crypto/crypto.go diff --git a/internal/mocks/crypto/crypto.go b/internal/mocks/crypto/crypto.go new file mode 100644 index 000000000..3681a97c8 --- /dev/null +++ b/internal/mocks/crypto/crypto.go @@ -0,0 +1,46 @@ +// Package crypto provides a lightweight fake implementation of +// security.Cryptor for use in tests. It lives in its own leaf package (no +// dependency on internal/usecase/devices) so it can be imported by both +// internal (package foo) and external (package foo_test) test files without +// creating an import cycle through internal/mocks. +package crypto + +import ( + "gopkg.in/yaml.v2" + + "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" +) + +type MockCrypto struct{} + +const encryptedData = "encrypted" + +// Encrypt encrypts a string. +func (c MockCrypto) Encrypt(_ string) (string, error) { + return encryptedData, nil +} + +// EncryptWithKey encrypts a string with the provided key. +func (c MockCrypto) EncryptWithKey(_, _ string) (string, error) { + return encryptedData, nil +} + +func (c MockCrypto) GenerateKey() string { + return "key" +} + +func (c MockCrypto) Decrypt(_ string) (string, error) { + return "decrypted", nil +} + +// ReadAndDecryptFile reads encrypted data from a file and decrypts it. +func (c MockCrypto) ReadAndDecryptFile(_ string) (config.Configuration, error) { + var configuration config.Configuration + + err := yaml.Unmarshal([]byte(""), &configuration) + if err != nil { + return config.Configuration{}, err + } + + return configuration, nil +} diff --git a/internal/mocks/crypto_mocks.go b/internal/mocks/crypto_mocks.go index 34bc53424..0b0c1473f 100644 --- a/internal/mocks/crypto_mocks.go +++ b/internal/mocks/crypto_mocks.go @@ -1,41 +1,11 @@ package mocks import ( - "gopkg.in/yaml.v2" - - "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" + crypto "github.com/device-management-toolkit/console/internal/mocks/crypto" ) -type MockCrypto struct{} - -const encryptedData = "encrypted" - -// Encrypt encrypts a string. -func (c MockCrypto) Encrypt(_ string) (string, error) { - return encryptedData, nil -} - -// Encrypt encrypts a string. -func (c MockCrypto) EncryptWithKey(_, _ string) (string, error) { - return encryptedData, nil -} - -func (c MockCrypto) GenerateKey() string { - return "key" -} - -func (c MockCrypto) Decrypt(_ string) (string, error) { - return "decrypted", nil -} - -// Read encrypted data from file and decrypt it. -func (c MockCrypto) ReadAndDecryptFile(_ string) (config.Configuration, error) { - var configuration config.Configuration - - err := yaml.Unmarshal([]byte(""), &configuration) - if err != nil { - return config.Configuration{}, err - } - - return configuration, nil -} +// MockCrypto is a fake security.Cryptor for tests. The implementation lives in +// the internal/mocks/crypto leaf package so it can be shared with internal +// (white-box) test files without an import cycle; this alias preserves the +// existing mocks.MockCrypto call sites. +type MockCrypto = crypto.MockCrypto diff --git a/internal/usecase/devices/usecase_private_test.go b/internal/usecase/devices/usecase_private_test.go index 14359f51a..0c9e682d0 100644 --- a/internal/usecase/devices/usecase_private_test.go +++ b/internal/usecase/devices/usecase_private_test.go @@ -5,27 +5,16 @@ import ( "github.com/stretchr/testify/require" - "github.com/device-management-toolkit/go-wsman-messages/v2/pkg/config" - "github.com/device-management-toolkit/console/internal/entity" dto "github.com/device-management-toolkit/console/internal/entity/dto/v1" + crypto "github.com/device-management-toolkit/console/internal/mocks/crypto" "github.com/device-management-toolkit/console/pkg/logger" ) -type stubCrypto struct{} - -func (stubCrypto) Encrypt(string) (string, error) { return "encrypted", nil } -func (stubCrypto) Decrypt(string) (string, error) { return "decrypted", nil } -func (stubCrypto) EncryptWithKey(string, string) (string, error) { return "encrypted", nil } -func (stubCrypto) GenerateKey() string { return "key" } -func (stubCrypto) ReadAndDecryptFile(string) (config.Configuration, error) { - return config.Configuration{}, nil -} - func TestDtoToEntity_DeviceInfoSerialization(t *testing.T) { t.Parallel() - uc := &UseCase{log: logger.New("error"), safeRequirements: stubCrypto{}} + uc := &UseCase{log: logger.New("error"), safeRequirements: crypto.MockCrypto{}} lms := true t.Run("returns empty DeviceInfo when nil", func(t *testing.T) { @@ -64,7 +53,7 @@ func TestDtoToEntity_DeviceInfoSerialization(t *testing.T) { func TestEntityToDTO_DeviceInfoDeserialization(t *testing.T) { t.Parallel() - uc := &UseCase{log: logger.New("error"), safeRequirements: stubCrypto{}} + uc := &UseCase{log: logger.New("error"), safeRequirements: crypto.MockCrypto{}} ent := &entity.Device{ GUID: "guid-1",