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 @@ -1665,6 +1665,12 @@
"pm.test(\"Status code is 200\", function () {\r",
" pm.response.to.have.status(200);\r",
"});\r",
"pm.test(\"Response includes server-managed identity fields\", function () {\r",
" var jsonData = pm.response.json();\r",
" pm.expect(jsonData.id).to.be.a(\"string\").and.not.empty;\r",
" pm.expect(jsonData.createdDate).to.be.a(\"string\").and.not.empty;\r",
" pm.expect(jsonData.lastUpdate).to.be.a(\"string\").and.not.empty;\r",
"});\r",
""
],
"type": "text/javascript"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*********************************************************************
* Copyright (c) Intel Corporation 2026
* SPDX-License-Identifier: Apache-2.0
**********************************************************************/

DROP INDEX IF EXISTS idx_devices_id;
ALTER TABLE devices DROP COLUMN connectiontype;
ALTER TABLE devices DROP COLUMN producttype;
ALTER TABLE devices DROP COLUMN deleteddate;
ALTER TABLE devices DROP COLUMN isdeleted;
ALTER TABLE devices DROP COLUMN lastupdate;
ALTER TABLE devices DROP COLUMN createddate;
ALTER TABLE devices DROP COLUMN id;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*********************************************************************
* Copyright (c) Intel Corporation 2026
* SPDX-License-Identifier: Apache-2.0
**********************************************************************/

-- Device identity & lifecycle columns (issue #843). TEXT cols are NOT NULL
-- DEFAULT '' so the ALTER backfills existing rows (modernc sqlite can't scan
-- NULL into a Go string); the partial index on id skips those empty backfills.
-- lastupdate is refreshed only on the main Update path, never by heartbeats,
-- and is intentionally unindexed. isdeleted/deleteddate: plumbing only (soft-
-- delete lands in a separate PR). producttype: vPro/ISM. connectiontype: CIRA/Direct.
ALTER TABLE devices ADD COLUMN id TEXT NOT NULL DEFAULT '';
ALTER TABLE devices ADD COLUMN createddate TEXT NOT NULL DEFAULT '';
ALTER TABLE devices ADD COLUMN lastupdate TEXT NOT NULL DEFAULT '';
ALTER TABLE devices ADD COLUMN isdeleted BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE devices ADD COLUMN deleteddate TEXT NOT NULL DEFAULT '';
ALTER TABLE devices ADD COLUMN producttype TEXT NOT NULL DEFAULT '';
ALTER TABLE devices ADD COLUMN connectiontype TEXT NOT NULL DEFAULT '';
Comment thread
madhavilosetty-intel marked this conversation as resolved.
Comment thread
madhavilosetty-intel marked this conversation as resolved.

CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_id ON devices (id) WHERE id <> '';
3 changes: 3 additions & 0 deletions internal/controller/httpapi/v1/devices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ var (
"usetls": true,
"allowselfsigned": true,
"certhash": true,
// isDeleted has no omitempty, so it is always present in a marshaled
// device body and thus always reported as a provided PATCH field.
"isdeleted": true,
}
)

Expand Down
7 changes: 7 additions & 0 deletions internal/entity/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ type Device struct {
MPSInstance string `bson:"mpsinstance"`
Hostname string `bson:"hostname"`
GUID string `bson:"guid"`
ID string `bson:"id"` // app-generated surrogate key; stable, immutable
CreatedDate string `bson:"createddate"` // server-set insert timestamp (RFC3339Nano UTC); immutable
LastUpdate string `bson:"lastupdate"` // server-set on insert + refreshed on record edits (RFC3339Nano UTC); NOT touched by heartbeats
IsDeleted bool `bson:"isdeleted"` // logical-deletion flag
DeletedDate string `bson:"deleteddate"` // server-set on soft-delete (RFC3339Nano UTC); read-only
ProductType string `bson:"producttype"` // manageability SKU (vPro/ISM)
ConnectionType string `bson:"connectiontype"` // device connection type (CIRA/Direct)
MPSUsername string `bson:"mpsusername"`
Tags string `bson:"tags"`
TenantID string `bson:"tenantid"`
Expand Down
7 changes: 7 additions & 0 deletions internal/entity/dto/v1/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ type Device struct {
UseTLS bool `json:"useTLS"`
AllowSelfSigned bool `json:"allowSelfSigned"`
CertHash string `json:"certHash"`
ID string `json:"id,omitempty"` // server-managed surrogate key; read-only
CreatedDate string `json:"createdDate,omitempty"` // server-set on insert; read-only
LastUpdate string `json:"lastUpdate,omitempty"` // server-set on insert + record edits; read-only
IsDeleted bool `json:"isDeleted"` // no omitempty: emit false to distinguish from absent
DeletedDate string `json:"deletedDate,omitempty"` // server-set on soft-delete; read-only
ProductType string `json:"productType,omitempty"` // manageability SKU (vPro/ISM)
ConnectionType string `json:"connectionType,omitempty"` // device connection type (CIRA/Direct)
Comment thread
madhavilosetty-intel marked this conversation as resolved.
}

type DeviceInfo struct {
Expand Down
57 changes: 57 additions & 0 deletions internal/entity/dto/v1/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,60 @@ func TestDeviceInfoJSONRoundTrip(t *testing.T) {
require.NotNil(t, decoded.LMSInstalled)
require.Equal(t, *info.LMSInstalled, *decoded.LMSInstalled)
}

// TestDevice_JSONContract locks two intentional serialization decisions for the
// identity/lifecycle columns on the /api/v1 device shape:
// - isDeleted has NO omitempty, so it is always emitted and callers can
// distinguish a false value from an absent field.
// - the other new fields ARE omitempty, so they stay absent on empty/legacy
// payloads and don't change the wire shape existing v1 consumers expect.
func TestDevice_JSONContract(t *testing.T) {
t.Parallel()

t.Run("zero value emits isDeleted but omits optional identity fields", func(t *testing.T) {
t.Parallel()

out := deviceJSONFields(t, Device{})

require.Contains(t, out, "isDeleted", "isDeleted must always be present")
require.JSONEq(t, `false`, string(out["isDeleted"]))

for _, k := range []string{"id", "createdDate", "deletedDate", "productType", "connectionType"} {
require.NotContains(t, out, k, "%s must be omitempty on an empty device", k)
}
})

t.Run("populated values serialize under the expected keys", func(t *testing.T) {
t.Parallel()

out := deviceJSONFields(t, Device{
ID: "id-1",
CreatedDate: "2026-05-26T12:00:00Z",
IsDeleted: true,
DeletedDate: "2026-05-27T08:00:00Z",
ProductType: "vpro",
ConnectionType: "CIRA",
})

require.JSONEq(t, `"id-1"`, string(out["id"]))
require.JSONEq(t, `"2026-05-26T12:00:00Z"`, string(out["createdDate"]))
require.JSONEq(t, `true`, string(out["isDeleted"]))
require.JSONEq(t, `"2026-05-27T08:00:00Z"`, string(out["deletedDate"]))
require.JSONEq(t, `"vpro"`, string(out["productType"]))
require.JSONEq(t, `"CIRA"`, string(out["connectionType"]))
})
}

// deviceJSONFields marshals d and returns its top-level JSON object keyed by
// field name, so a test can assert on key presence/absence and values.
func deviceJSONFields(t *testing.T, d Device) map[string]json.RawMessage {
t.Helper()

b, err := json.Marshal(d)
require.NoError(t, err)

var m map[string]json.RawMessage
require.NoError(t, json.Unmarshal(b, &m))

return m
}
11 changes: 11 additions & 0 deletions internal/usecase/devices/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"

"github.com/google/uuid"

Expand Down Expand Up @@ -216,6 +217,9 @@ func (uc *UseCase) Update(ctx context.Context, d *dto.Device, fields map[string]
return nil, err
}

// Refreshed on every record edit; dtoToEntity doesn't copy it from the DTO.
d1.LastUpdate = time.Now().UTC().Format(time.RFC3339Nano)

updated, err := uc.repo.Update(ctx, d1)
if err != nil {
return nil, ErrDatabase.Wrap("Update", "uc.repo.Update", err)
Expand Down Expand Up @@ -251,6 +255,13 @@ func (uc *UseCase) Insert(ctx context.Context, d *dto.Device) (*dto.Device, erro
d1.GUID = uuid.New().String()
}

// Server-managed: client values ignored. Nanosecond UTC keeps createddate
// sortable and collision-free within a second.
d1.ID = uuid.New().String()
d1.CreatedDate = time.Now().UTC().Format(time.RFC3339Nano)
// Starts at createddate; only the main Update path refreshes it, never heartbeats.
d1.LastUpdate = d1.CreatedDate

_, err = uc.repo.Insert(ctx, d1)
if err != nil {
return nil, ErrDatabase.Wrap("Insert", "uc.repo.Insert", err)
Expand Down
78 changes: 70 additions & 8 deletions internal/usecase/devices/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package devices_test
import (
"context"
"encoding/json"
"fmt"
"reflect"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -24,6 +26,61 @@ func boolPtr(v bool) *bool {
return &v
}

// insertedDevice matches the entity passed to repo.Insert, asserting the
// server-set ID/CreatedDate/LastUpdate are populated (LastUpdate == CreatedDate)
// while ignoring their non-deterministic values.
type insertedDevice struct{ want *entity.Device }

func (m insertedDevice) Matches(x any) bool {
got, ok := x.(*entity.Device)
if !ok || got == nil {
return false
}

if got.ID == "" || got.CreatedDate == "" {
return false
}

if got.LastUpdate != got.CreatedDate {
return false
}

cp := *got
cp.ID = ""
cp.CreatedDate = ""
cp.LastUpdate = ""

return reflect.DeepEqual(&cp, m.want)
}

func (m insertedDevice) String() string {
return fmt.Sprintf("matches %+v (ignoring server-set ID/CreatedDate/LastUpdate)", m.want)
}

// updatedDevice matches the entity passed to repo.Update, asserting the
// server-set LastUpdate was refreshed (non-empty) while ignoring its value.
type updatedDevice struct{ want *entity.Device }

func (m updatedDevice) Matches(x any) bool {
got, ok := x.(*entity.Device)
if !ok || got == nil {
return false
}

if got.LastUpdate == "" {
return false
}

cp := *got
cp.LastUpdate = ""

return reflect.DeepEqual(&cp, m.want)
}

func (m updatedDevice) String() string {
return fmt.Sprintf("matches %+v (ignoring server-set LastUpdate)", m.want)
}

type testUsecase struct {
name string
guid string
Expand Down Expand Up @@ -321,7 +378,7 @@ func TestUpdate(t *testing.T) {
name: "successful update",
mock: func(repo *mocks.MockDeviceManagementRepository, management *mocks.MockWSMAN) {
repo.EXPECT().
Update(context.Background(), device).
Update(context.Background(), updatedDevice{device}).
Return(true, nil)
repo.EXPECT().
GetByID(context.Background(), "device-guid-123", "tenant-id-456").
Expand All @@ -336,7 +393,7 @@ func TestUpdate(t *testing.T) {
name: "update fails - not found",
mock: func(repo *mocks.MockDeviceManagementRepository, _ *mocks.MockWSMAN) {
repo.EXPECT().
Update(context.Background(), device).
Update(context.Background(), updatedDevice{device}).
Return(false, nil)
},
res: (*dto.Device)(nil),
Expand All @@ -346,7 +403,7 @@ func TestUpdate(t *testing.T) {
name: "update fails - database error",
mock: func(repo *mocks.MockDeviceManagementRepository, _ *mocks.MockWSMAN) {
repo.EXPECT().
Update(context.Background(), device).
Update(context.Background(), updatedDevice{device}).
Return(false, devices.ErrDatabase)
},
res: (*dto.Device)(nil),
Expand Down Expand Up @@ -387,7 +444,7 @@ func TestInsert(t *testing.T) {
}

repo.EXPECT().
Insert(context.Background(), device).
Insert(context.Background(), insertedDevice{want: device}).
Return("unique-device-id", nil)
repo.EXPECT().
GetByID(context.Background(), device.GUID, "tenant-id-456").
Expand All @@ -408,7 +465,7 @@ func TestInsert(t *testing.T) {
}

repo.EXPECT().
Insert(context.Background(), device).
Insert(context.Background(), insertedDevice{want: device}).
Return("", devices.ErrDatabase)
},
res: (*dto.Device)(nil),
Expand Down Expand Up @@ -483,7 +540,7 @@ func TestUpdateWithPasswords(t *testing.T) {
useCase, repo, management := devicesTest(t)

repo.EXPECT().
Update(context.Background(), deviceWithPasswords).
Update(context.Background(), updatedDevice{deviceWithPasswords}).
Return(true, nil)
repo.EXPECT().
GetByID(context.Background(), "device-guid-123", "tenant-id-456").
Expand Down Expand Up @@ -516,7 +573,7 @@ func TestInsertWithPasswords(t *testing.T) {
}

repo.EXPECT().
Insert(context.Background(), deviceWithPasswords).
Insert(context.Background(), insertedDevice{want: deviceWithPasswords}).
Return("unique-device-id", nil)
repo.EXPECT().
GetByID(context.Background(), "device-guid-123", "tenant-id-456").
Expand Down Expand Up @@ -724,7 +781,7 @@ func TestUpdate_UUIDNormalization(t *testing.T) {
}

repo.EXPECT().
Update(context.Background(), expectedEntity).
Update(context.Background(), updatedDevice{expectedEntity}).
Return(true, nil)
repo.EXPECT().
GetByID(context.Background(), "aaf0c395-c2a2-992e-5655-48210b50d8c9", "tenant-id-456").
Expand Down Expand Up @@ -831,10 +888,15 @@ func TestUpdatePartial(t *testing.T) {
require.NotNil(t, actualEntity)
require.JSONEq(t, expectedDeviceInfoJSON, actualEntity.DeviceInfo)

// Update stamps a fresh server-managed LastUpdate; assert it was
// set, then zero it for the field-by-field comparison below.
require.NotEmpty(t, actualEntity.LastUpdate)

expectedWithoutInfo := *expectedEntity
actualWithoutInfo := *actualEntity
expectedWithoutInfo.DeviceInfo = ""
actualWithoutInfo.DeviceInfo = ""
actualWithoutInfo.LastUpdate = ""
require.Equal(t, expectedWithoutInfo, actualWithoutInfo)

var actualInfo dto.DeviceInfo
Expand Down
16 changes: 16 additions & 0 deletions internal/usecase/devices/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ func (uc *UseCase) dtoToEntity(d *dto.Device) (*entity.Device, error) {
Password: d.Password,
UseTLS: d.UseTLS,
AllowSelfSigned: d.AllowSelfSigned,
// ID/CreatedDate/LastUpdate are server-managed: set by Insert/Update, not
// copied from the DTO. Leaving them zero keeps server-authority structural.
IsDeleted: d.IsDeleted,
ProductType: d.ProductType,
ConnectionType: d.ConnectionType,
}

d1.Password, err = uc.safeRequirements.Encrypt(d1.Password)
Expand Down Expand Up @@ -160,6 +165,10 @@ var deviceFieldSetters = map[string]func(dst, src *dto.Device){
"allowselfsigned": func(dst, src *dto.Device) { dst.AllowSelfSigned = src.AllowSelfSigned },
"certhash": func(dst, src *dto.Device) { dst.CertHash = src.CertHash },
deviceInfoFieldKey: func(dst, src *dto.Device) { dst.DeviceInfo = src.DeviceInfo },
// id, createdDate, and lastUpdate are server-managed and intentionally not patchable.
"isdeleted": func(dst, src *dto.Device) { dst.IsDeleted = src.IsDeleted },
"producttype": func(dst, src *dto.Device) { dst.ProductType = src.ProductType },
"connectiontype": func(dst, src *dto.Device) { dst.ConnectionType = src.ConnectionType },
}

var deviceInfoFieldSetters = map[string]func(dst, src *dto.DeviceInfo){
Expand Down Expand Up @@ -267,6 +276,13 @@ func (uc *UseCase) entityToDTO(d *entity.Device) (*dto.Device, error) {
Username: d.Username,
UseTLS: d.UseTLS,
AllowSelfSigned: d.AllowSelfSigned,
ID: d.ID,
CreatedDate: d.CreatedDate,
LastUpdate: d.LastUpdate,
IsDeleted: d.IsDeleted,
DeletedDate: d.DeletedDate,
ProductType: d.ProductType,
ConnectionType: d.ConnectionType,
}

if d.CertHash != nil {
Expand Down
Loading
Loading