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 @@ -1180,6 +1180,11 @@
"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",
"});\r",
""
],
"type": "text/javascript"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*********************************************************************
* 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 createddate;
ALTER TABLE devices DROP COLUMN id;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*********************************************************************
* Copyright (c) Intel Corporation 2026
* SPDX-License-Identifier: Apache-2.0
**********************************************************************/

-- Device identity & lifecycle columns (issue #843).
-- All TEXT columns are NOT NULL DEFAULT '' so the ALTER backfills existing
-- rows with a non-NULL value (the modernc sqlite driver cannot scan NULL into
-- a Go string). `id` is an app-generated surrogate key; the partial unique
-- index excludes the backfilled empty values on pre-existing rows.
-- createddate: server-set insert timestamp. isdeleted/deleteddate: logical-
-- deletion flag + server-set timestamp (column + plumbing only; soft-delete
-- behavior lands in a separate PR). producttype: manageability SKU (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 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: 6 additions & 1 deletion internal/entity/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ 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
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 All @@ -26,7 +32,6 @@ type Device struct {
AllowSelfSigned bool `bson:"allowselfsigned"`
CertHash *string `bson:"certhash"`
}

type Explorer struct {
XMLInput string
XMLOutput string
Expand Down
6 changes: 6 additions & 0 deletions internal/entity/dto/v1/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ 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
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
65 changes: 65 additions & 0 deletions internal/entity/dto/v1/device_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package dto

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"
)

// 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
}
8 changes: 8 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 @@ -251,6 +252,13 @@ func (uc *UseCase) Insert(ctx context.Context, d *dto.Device) (*dto.Device, erro
d1.GUID = uuid.New().String()
}

// id and createddate are server-managed: a stable surrogate key and
// the insertion timestamp. Client-supplied values are ignored. Nanosecond
// precision (UTC) keeps the string lexicographically sortable and avoids
// collisions when many devices are added within the same second.
d1.ID = uuid.New().String()
d1.CreatedDate = time.Now().UTC().Format(time.RFC3339Nano)
Comment thread
madhavilosetty-intel marked this conversation as resolved.

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

import (
"context"
"fmt"
"reflect"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -18,6 +20,32 @@ func ptr(s string) *string {
return &s
}

// insertedDevice matches the entity passed to repo.Insert, ignoring the
// server-generated ID and CreatedDate (which are non-deterministic) while
// asserting they were populated.
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
}

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

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

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

type testUsecase struct {
name string
guid string
Expand Down Expand Up @@ -377,7 +405,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 @@ -398,7 +426,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 @@ -506,7 +534,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
17 changes: 17 additions & 0 deletions internal/usecase/devices/usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ func (uc *UseCase) dtoToEntity(d *dto.Device) (*entity.Device, error) {
Password: d.Password,
UseTLS: d.UseTLS,
AllowSelfSigned: d.AllowSelfSigned,
// ID and CreatedDate are server-managed and deliberately NOT copied
// from the inbound DTO: UseCase.Insert sets them after this conversion,
// and the repo Update layer never writes them. Leaving them zero here
// makes immutability structural rather than reliant on every call site.
IsDeleted: d.IsDeleted,
ProductType: d.ProductType,
ConnectionType: d.ConnectionType,
}

d1.Password, err = uc.safeRequirements.Encrypt(d1.Password)
Expand Down Expand Up @@ -157,6 +164,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 },
"deviceinfo": func(dst, src *dto.Device) { dst.DeviceInfo = src.DeviceInfo },
// id and createdDate 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 },
}

func mergeDeviceFields(dst, src *dto.Device, fields map[string]bool) {
Expand Down Expand Up @@ -197,6 +208,12 @@ 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,
IsDeleted: d.IsDeleted,
DeletedDate: d.DeletedDate,
ProductType: d.ProductType,
ConnectionType: d.ConnectionType,
}

if d.CertHash != nil {
Expand Down
19 changes: 18 additions & 1 deletion internal/usecase/nosqldb/mongo/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,24 @@ func ensureIndexes(ctx context.Context, db *mongo.Database, log logger.Interface
return fmt.Errorf("create case-insensitive unique index on %s: %w", CollectionDomains, err)
}

log.Info("mongo unique indexes ensured (%d total)", len(tenantScoped)+1)
// Mirrors the SQL partial unique index idx_devices_id (WHERE id <> ''): the
// partial filter excludes legacy docs without an `id` and the empty default,
// so only populated surrogate keys are constrained to be unique.
if _, err := db.Collection(CollectionDevices).Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: fieldID, Value: 1}},
Options: options.Index().
SetUnique(true).
SetPartialFilterExpression(bson.M{fieldID: bson.M{"$gt": ""}}).
SetName("idx_devices_id"),
}); err != nil {
return fmt.Errorf("create unique index on %s.id: %w", CollectionDevices, err)
}

// globalIndexes: the two collection-global unique indexes created above
// (domains case-insensitive + devices id) that aren't in tenantScoped.
const globalIndexes = 2

log.Info("mongo unique indexes ensured (%d total)", len(tenantScoped)+globalIndexes)

return nil
}
10 changes: 8 additions & 2 deletions internal/usecase/nosqldb/mongo/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ func (r *DeviceRepo) Update(ctx context.Context, d *entity.Device) (bool, error)
}

// Explicit field list mirrors sqldb/device.go:Update so a new field must be wired in intentionally.
res, err := r.col.UpdateOne(ctx,
res, err := r.col.UpdateOne(
ctx,
bson.M{fieldGUID: d.GUID, fieldTenantID: d.TenantID},
bson.M{opSet: bson.M{
fieldGUID: d.GUID,
Expand All @@ -218,6 +219,10 @@ func (r *DeviceRepo) Update(ctx context.Context, d *entity.Device) (bool, error)
"usetls": d.UseTLS,
"allowselfsigned": d.AllowSelfSigned,
"certhash": d.CertHash,
// id, createddate, and deleteddate are immutable — intentionally not $set here.
"isdeleted": d.IsDeleted,
"producttype": d.ProductType,
"connectiontype": d.ConnectionType,
}},
)
if err != nil {
Expand Down Expand Up @@ -254,7 +259,8 @@ func (r *DeviceRepo) UpdateLastSeen(ctx context.Context, guid string) error {
return errDeviceDatabase.Wrap("UpdateLastSeen", "validate", nil)
}

_, err := r.col.UpdateOne(ctx,
_, err := r.col.UpdateOne(
ctx,
bson.M{fieldGUID: guid},
bson.M{opSet: bson.M{"lastseen": time.Now()}},
)
Expand Down
Loading
Loading