Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1633,7 +1633,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "{\r\n \"guid\": \"143e4567-e89b-12d3-a456-426614174000\",\r\n \"friendlyName\": \"friendlyName\",\r\n \"hostname\": \"hostname\",\r\n \"tags\": [],\r\n \"mpsusername\": \"admin\",\r\n \"deviceInfo\": {\r\n \"fwVersion\": \"16.1.30\",\r\n \"fwBuild\": \"3400\",\r\n \"fwSku\": \"11\",\r\n \"discovered\": true,\r\n \"currentMode\": \"Admin\",\r\n \"features\": \"SOL,IDER,KVM\",\r\n \"ipAddress\": \"10.0.0.12\",\r\n \"lastUpdated\": \"2026-05-21T00:00:00Z\",\r\n \"tlsMode\": \"TLS 1.2\",\r\n \"upid\": {\r\n \"oemPlatformIdType\": \"Not Set (0)\",\r\n \"oemId\": \"\",\r\n \"csmeId\": \"4A45A39C5ED9462082510000\"\r\n },\r\n \"amtEnabledInBIOS\": true,\r\n \"meInterfaceVersion\": \"16.1.25.2124\",\r\n \"dhcpEnabled\": true,\r\n \"certHashes\": [\r\n \"a1b2c3\",\r\n \"d4e5f6\"\r\n ],\r\n \"lmsInstalled\": true,\r\n \"lmsVersion\": \"2410.5.0.0\",\r\n \"osName\": \"linux\",\r\n \"osVersion\": \"6.8.0-51-generic\",\r\n \"osDistro\": \"Ubuntu 24.04 LTS\",\r\n \"cpuModel\": \"Intel(R) Core(TM) Ultra 7 165H\",\r\n \"osIpAddress\": \"10.49.76.163\",\r\n \"ethernetAdapterCount\": 2,\r\n \"monitorConnected\": true,\r\n \"ieee8021xEnabled\": false\r\n }\r\n}",
"raw": "{\r\n \"guid\": \"143e4567-e89b-12d3-a456-426614174000\",\r\n \"friendlyName\": \"friendlyName\",\r\n \"hostname\": \"hostname\",\r\n \"tags\": [],\r\n \"mpsusername\": \"admin\",\r\n \"deviceInfo\": {\r\n \"fwVersion\": \"16.1.30\",\r\n \"fwBuild\": \"3400\",\r\n \"fwSku\": \"11\",\r\n \"discovered\": true,\r\n \"firstDiscovered\": \"2026-05-20T00:00:00Z\",\r\n \"currentMode\": \"Admin\",\r\n \"features\": \"SOL,IDER,KVM\",\r\n \"ipAddress\": \"10.0.0.12\",\r\n \"lastSynced\": \"2026-05-21T00:00:00Z\",\r\n \"tlsMode\": \"TLS 1.2\",\r\n \"upid\": {\r\n \"oemPlatformIdType\": \"Not Set (0)\",\r\n \"oemId\": \"\",\r\n \"csmeId\": \"4A45A39C5ED9462082510000\"\r\n },\r\n \"amtEnabledInBIOS\": true,\r\n \"meInterfaceVersion\": \"16.1.25.2124\",\r\n \"dhcpEnabled\": true,\r\n \"certHashes\": [\r\n \"a1b2c3\",\r\n \"d4e5f6\"\r\n ],\r\n \"lmsInstalled\": true,\r\n \"lmsVersion\": \"2410.5.0.0\",\r\n \"osName\": \"linux\",\r\n \"osVersion\": \"6.8.0-51-generic\",\r\n \"osDistro\": \"Ubuntu 24.04 LTS\",\r\n \"cpuModel\": \"Intel(R) Core(TM) Ultra 7 165H\",\r\n \"osIpAddress\": \"10.49.76.163\",\r\n \"ethernetAdapterCount\": 2,\r\n \"monitorConnected\": true,\r\n \"ieee8021xEnabled\": false\r\n }\r\n}",
"options": {
"raw": {
"language": "json"
Expand Down
22 changes: 12 additions & 10 deletions internal/controller/httpapi/v1/devices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,15 +528,16 @@ func TestDevicesInsertAcceptsFullDeviceInfo(t *testing.T) {
GUID: testDeviceGUID,
Hostname: "test-device",
DeviceInfo: &dto.DeviceInfo{
FWVersion: "16.1.30",
FWBuild: "3400",
FWSku: "11",
Discovered: &discovered,
CurrentMode: "Admin",
Features: "SOL,IDER,KVM",
IPAddress: "10.0.0.12",
LastUpdated: &timeNow,
TLSMode: "TLS 1.2",
FWVersion: "16.1.30",
FWBuild: "3400",
FWSku: "11",
Discovered: &discovered,
CurrentMode: "Admin",
Features: "SOL,IDER,KVM",
IPAddress: "10.0.0.12",
FirstDiscovered: &timeNow,
LastSynced: &timeNow,
TLSMode: "TLS 1.2",
UPID: map[string]json.RawMessage{
"oemPlatformIdType": json.RawMessage(`"Not Set (0)"`),
"oemId": json.RawMessage(`""`),
Expand Down Expand Up @@ -576,7 +577,8 @@ func TestDevicesInsertAcceptsFullDeviceInfo(t *testing.T) {
"currentMode":"Admin",
"features":"SOL,IDER,KVM",
"ipAddress":"10.0.0.12",
"lastUpdated":"` + timeNow.Format(time.RFC3339Nano) + `",
"firstDiscovered":"` + timeNow.Format(time.RFC3339Nano) + `",
"lastSynced":"` + timeNow.Format(time.RFC3339Nano) + `",
"tlsMode":"TLS 1.2",
"upid":{
"oemPlatformIdType":"Not Set (0)",
Expand Down
6 changes: 4 additions & 2 deletions internal/controller/openapi/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ func (f *FuegoAdapter) getDeviceByID(_ fuego.ContextNoBody) (dto.Device, error)
}

func exampleDeviceInfo() *dto.DeviceInfo {
lastUpdated := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)
firstDiscovered := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
lastSynced := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)
lmsInstalled := true
amtEnabledInBIOS := true
dhcpEnabled := true
Expand All @@ -251,10 +252,11 @@ func exampleDeviceInfo() *dto.DeviceInfo {
FWVersion: "16.1.30",
FWBuild: "3400",
FWSku: "11",
FirstDiscovered: &firstDiscovered,
CurrentMode: "Admin",
Features: "SOL,IDER,KVM",
IPAddress: "10.0.0.12",
LastUpdated: &lastUpdated,
LastSynced: &lastSynced,
LMSInstalled: &lmsInstalled,
LMSVersion: "2410.5.0.0",
TLSMode: "TLS 1.2",
Expand Down
26 changes: 25 additions & 1 deletion internal/entity/dto/v1/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ type DeviceInfo struct {
FWBuild string `json:"fwBuild"`
FWSku string `json:"fwSku"`
Discovered *bool `json:"discovered,omitempty"`
FirstDiscovered *time.Time `json:"firstDiscovered,omitempty"`
CurrentMode string `json:"currentMode"`
Features string `json:"features"`
IPAddress string `json:"ipAddress"`
LastUpdated *time.Time `json:"lastUpdated,omitempty"`
LastSynced *time.Time `json:"lastSynced,omitempty"`
Comment thread
sinchubhat marked this conversation as resolved.
LMSInstalled *bool `json:"lmsInstalled,omitempty"`
LMSVersion string `json:"lmsVersion,omitempty"`
TLSMode string `json:"tlsMode,omitempty"`
Expand All @@ -64,6 +65,29 @@ type DeviceInfo struct {
IEEE8021XEnabled *bool `json:"ieee8021xEnabled,omitempty"`
}

// UnmarshalJSON implements custom JSON deserialization to support backwards compatibility
// for the lastUpdated -> lastSynced field rename. Existing clients may send the old
// "lastUpdated" key; this method migrates it to the new "lastSynced" field if present.
func (d *DeviceInfo) UnmarshalJSON(data []byte) error {
Comment thread
sinchubhat marked this conversation as resolved.
Comment thread
sinchubhat marked this conversation as resolved.
type Alias DeviceInfo

type deviceInfoCompat struct {
*Alias
LegacyLastUpdated *time.Time `json:"lastUpdated,omitempty"`
}

aux := deviceInfoCompat{Alias: (*Alias)(d)}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}

if d.LastSynced == nil && aux.LegacyLastUpdated != nil {
d.LastSynced = aux.LegacyLastUpdated
}

return nil
}

type Explorer struct {
XMLInput string `json:"xmlInput"`
XMLOutput string `json:"xmlOutput"`
Expand Down
38 changes: 28 additions & 10 deletions internal/entity/dto/v1/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@ func TestDeviceInfoJSONRoundTrip(t *testing.T) {
ethernetAdapterCount := 2
monitorConnected := true
ieee8021xEnabled := false
lastUpdated := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)
firstDiscovered := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
lastSynced := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)

info := DeviceInfo{
FWVersion: "16.1.30",
FWBuild: "3400",
FWSku: "11",
Discovered: &discovered,
CurrentMode: "Admin",
Features: "SOL,IDER,KVM",
IPAddress: "10.0.0.12",
LastUpdated: &lastUpdated,
TLSMode: "TLS 1.2",
FWVersion: "16.1.30",
FWBuild: "3400",
FWSku: "11",
Discovered: &discovered,
FirstDiscovered: &firstDiscovered,
CurrentMode: "Admin",
Features: "SOL,IDER,KVM",
IPAddress: "10.0.0.12",
LastSynced: &lastSynced,
TLSMode: "TLS 1.2",
UPID: map[string]json.RawMessage{
"oemPlatformIdType": json.RawMessage(`"Not Set (0)"`),
"oemId": json.RawMessage(`""`),
Expand Down Expand Up @@ -65,4 +67,20 @@ func TestDeviceInfoJSONRoundTrip(t *testing.T) {
require.Equal(t, *info.Discovered, *decoded.Discovered)
require.NotNil(t, decoded.LMSInstalled)
require.Equal(t, *info.LMSInstalled, *decoded.LMSInstalled)
require.NotNil(t, decoded.FirstDiscovered)
require.Equal(t, *info.FirstDiscovered, *decoded.FirstDiscovered)
require.NotNil(t, decoded.LastSynced)
require.Equal(t, *info.LastSynced, *decoded.LastSynced)
}

func TestDeviceInfoJSONLegacyLastUpdatedMapsToLastSynced(t *testing.T) {
t.Parallel()

legacy := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)
payload := []byte(`{"fwVersion":"16.1.30","lastUpdated":"` + legacy.Format(time.RFC3339Nano) + `"}`)

var decoded DeviceInfo
require.NoError(t, json.Unmarshal(payload, &decoded))
require.NotNil(t, decoded.LastSynced)
require.Equal(t, legacy, *decoded.LastSynced)
}
177 changes: 159 additions & 18 deletions internal/usecase/devices/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"testing"
"time"

"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
Expand Down Expand Up @@ -759,33 +760,40 @@ func TestUpdatePartial(t *testing.T) {
MEBXPassword: ptr("encrypted-mebx"),
}

firstDiscovered := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
lastSynced := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)

// Client sends guid, a new hostname, and a partial deviceInfo update.
discovered := true
incoming := &dto.Device{
GUID: "device-guid-123",
TenantID: "tenant-id-456",
Hostname: "new-hostname",
DeviceInfo: &dto.DeviceInfo{
FWVersion: "16.1.30",
Discovered: &discovered,
IPAddress: "10.0.0.55",
LMSInstalled: boolPtr(true),
FWVersion: "16.1.30",
Discovered: &discovered,
FirstDiscovered: &firstDiscovered,
IPAddress: "10.0.0.55",
LastSynced: &lastSynced,
LMSInstalled: boolPtr(true),
},
}
fields := map[string]bool{
"guid": true,
"tenantId": true,
"hostname": true,
"deviceinfo": true,
"deviceinfo.fwversion": true,
"deviceinfo.discovered": true,
"deviceinfo.ipaddress": true,
"deviceinfo.lmsinstalled": true,
"guid": true,
"tenantId": true,
"hostname": true,
"deviceinfo": true,
"deviceinfo.fwversion": true,
"deviceinfo.discovered": true,
"deviceinfo.firstdiscovered": true,
"deviceinfo.ipaddress": true,
"deviceinfo.lastsynced": true,
"deviceinfo.lmsinstalled": true,
}

// After merge + dtoToEntity (MockCrypto re-encrypts plaintext to "encrypted"):
// Only fields without omitempty tag are included in JSON for zero values.
expectedDeviceInfoJSON := `{"fwVersion":"16.1.30","fwBuild":"3400","fwSku":"","discovered":true,"currentMode":"","features":"","ipAddress":"10.0.0.55","lmsInstalled":true}`
expectedDeviceInfoJSON := `{"fwVersion":"16.1.30","fwBuild":"3400","fwSku":"","discovered":true,"firstDiscovered":"2026-05-20T00:00:00Z","currentMode":"","features":"","ipAddress":"10.0.0.55","lastSynced":"2026-05-21T00:00:00Z","lmsInstalled":true}`
expectedEntity := &entity.Device{
GUID: "device-guid-123",
TenantID: "tenant-id-456",
Expand All @@ -804,11 +812,13 @@ func TestUpdatePartial(t *testing.T) {
TenantID: "tenant-id-456",
Hostname: "new-hostname",
DeviceInfo: &dto.DeviceInfo{
FWVersion: "16.1.30",
FWBuild: "3400",
Discovered: boolPtr(true),
IPAddress: "10.0.0.55",
LMSInstalled: boolPtr(true),
FWVersion: "16.1.30",
FWBuild: "3400",
Discovered: boolPtr(true),
FirstDiscovered: &firstDiscovered,
IPAddress: "10.0.0.55",
LastSynced: &lastSynced,
LMSInstalled: boolPtr(true),
},
Tags: []string{"lab", "floor-2"},
MPSUsername: "admin",
Expand Down Expand Up @@ -886,6 +896,137 @@ func TestUpdatePartial(t *testing.T) {
})
}

// TestUpdatePreservesFirstDiscovered verifies firstDiscovered and discovered are
// immutable once set: a client PATCH cannot overwrite the stored values, while
// lastSynced still advances.
func TestUpdatePreservesFirstDiscovered(t *testing.T) {
t.Parallel()

original := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)

// Stored record already discovered on 2026-05-20.
existing := &entity.Device{
GUID: "device-guid-123",
TenantID: "tenant-id-456",
DeviceInfo: `{"discovered":false,"firstDiscovered":"2026-05-20T00:00:00Z","lastSynced":"2026-05-20T00:00:00Z"}`,
Password: "encrypted-amt",
}

// Client tries to change firstDiscovered/discovered and bumps lastSynced.
tampered := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
newSync := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
discoveredTrue := true
incoming := &dto.Device{
GUID: "device-guid-123",
TenantID: "tenant-id-456",
DeviceInfo: &dto.DeviceInfo{
Discovered: &discoveredTrue,
FirstDiscovered: &tampered,
LastSynced: &newSync,
},
}
fields := map[string]bool{
"deviceinfo": true,
"deviceinfo.discovered": true,
"deviceinfo.firstdiscovered": true,
"deviceinfo.lastsynced": true,
}

useCase, repo, management := devicesTest(t)

repo.EXPECT().
GetByID(context.Background(), "device-guid-123", "tenant-id-456").
Return(existing, nil)
repo.EXPECT().
Update(context.Background(), gomock.Any()).
DoAndReturn(func(_ context.Context, actualEntity *entity.Device) (bool, error) {
var info dto.DeviceInfo
require.NoError(t, json.Unmarshal([]byte(actualEntity.DeviceInfo), &info))

// Immutable fields keep their stored values.
require.NotNil(t, info.FirstDiscovered)
require.Equal(t, original, *info.FirstDiscovered)
require.NotNil(t, info.Discovered)
require.False(t, *info.Discovered)

// lastSynced advances.
require.NotNil(t, info.LastSynced)
require.Equal(t, newSync, *info.LastSynced)

return true, nil
})
repo.EXPECT().
GetByID(context.Background(), "device-guid-123", "tenant-id-456").
Return(existing, nil)
management.EXPECT().DestroyWsmanClient(gomock.Any())

_, err := useCase.Update(context.Background(), incoming, fields)
require.NoError(t, err)
}

// TestUpdatePreservesFirstDiscoveredWholesale covers the backward-compat path where
// the caller sends only a top-level "deviceinfo" key (no nested deviceinfo.* keys),
// which replaces DeviceInfo wholesale. firstDiscovered/discovered must still be kept
// from the stored record.
func TestUpdatePreservesFirstDiscoveredWholesale(t *testing.T) {
t.Parallel()

original := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)

existing := &entity.Device{
GUID: "device-guid-123",
TenantID: "tenant-id-456",
DeviceInfo: `{"discovered":false,"firstDiscovered":"2026-05-20T00:00:00Z","lastSynced":"2026-05-20T00:00:00Z"}`,
Password: "encrypted-amt",
}

tampered := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
newSync := time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
discoveredTrue := true
incoming := &dto.Device{
GUID: "device-guid-123",
TenantID: "tenant-id-456",
DeviceInfo: &dto.DeviceInfo{
Discovered: &discoveredTrue,
FirstDiscovered: &tampered,
LastSynced: &newSync,
},
}
// Only the top-level key — no nested deviceinfo.* keys.
fields := map[string]bool{"deviceinfo": true}

useCase, repo, management := devicesTest(t)

repo.EXPECT().
GetByID(context.Background(), "device-guid-123", "tenant-id-456").
Return(existing, nil)
repo.EXPECT().
Update(context.Background(), gomock.Any()).
DoAndReturn(func(_ context.Context, actualEntity *entity.Device) (bool, error) {
var info dto.DeviceInfo
require.NoError(t, json.Unmarshal([]byte(actualEntity.DeviceInfo), &info))

// Immutable fields keep their stored values even on wholesale replace.
require.NotNil(t, info.FirstDiscovered)
require.Equal(t, original, *info.FirstDiscovered)
require.NotNil(t, info.Discovered)
require.False(t, *info.Discovered)

// Non-immutable field is taken from the incoming payload.
require.NotNil(t, info.LastSynced)
require.Equal(t, newSync, *info.LastSynced)

return true, nil
})
repo.EXPECT().
GetByID(context.Background(), "device-guid-123", "tenant-id-456").
Return(existing, nil)
management.EXPECT().DestroyWsmanClient(gomock.Any())

_, err := useCase.Update(context.Background(), incoming, fields)
require.NoError(t, err)
}

func TestUpdateConnectionStatus(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading