diff --git a/data/initMPS.sql b/data/initMPS.sql index 10549700b..8a1420d54 100644 --- a/data/initMPS.sql +++ b/data/initMPS.sql @@ -20,6 +20,9 @@ CREATE TABLE IF NOT EXISTS devices( lastseen timestamp with time zone, lastdisconnected timestamp with time zone, deviceinfo JSON, + powerstate integer, + ospowersavingstate integer, + powerstateupdatedat timestamp with time zone, CONSTRAINT device_guid UNIQUE(guid), PRIMARY KEY (guid,tenantid) ); diff --git a/src/data/mongo/collections/device.test.ts b/src/data/mongo/collections/device.test.ts index 04f12243e..057f95c61 100644 --- a/src/data/mongo/collections/device.test.ts +++ b/src/data/mongo/collections/device.test.ts @@ -22,6 +22,7 @@ describe('MongoDeviceTable', () => { insertOne: vi.fn(), findOneAndUpdate: vi.fn(), distinct: vi.fn(), + updateOne: vi.fn(), updateMany: vi.fn() } as any @@ -156,6 +157,47 @@ describe('MongoDeviceTable', () => { expect(result).toEqual(mockData) }) + it('should update the power state for a device', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + collection.updateOne.mockResolvedValue({ matchedCount: 1 } as any) + + const result = await mongoDeviceTable.updatePowerState('someGuid', 4, 2, updatedAt, 'someTenantId') + + expect(result).toBe(true) + expect(collection.updateOne).toHaveBeenCalledWith( + { guid: 'someGuid', tenantId: 'someTenantId' }, + { $set: { powerState: 4, osPowerSavingState: 2, powerStateUpdatedAt: updatedAt } } + ) + }) + + it('should default to an empty tenantId when the power state update omits it', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + collection.updateOne.mockResolvedValue({ matchedCount: 1 } as any) + + await mongoDeviceTable.updatePowerState('someGuid', 4, 2, updatedAt) + + expect(collection.updateOne).toHaveBeenCalledWith( + { guid: 'someGuid', tenantId: '' }, + { $set: { powerState: 4, osPowerSavingState: 2, powerStateUpdatedAt: updatedAt } } + ) + }) + + it('should return false when no device matches the power state update', async () => { + collection.updateOne.mockResolvedValue({ matchedCount: 0 } as any) + + const result = await mongoDeviceTable.updatePowerState('someGuid', 4, 2, new Date(), 'someTenantId') + + expect(result).toBe(false) + }) + + it('should reject when the power state update fails so the caller can back off', async () => { + collection.updateOne.mockRejectedValue(new Error('mongo is down')) + + await expect(mongoDeviceTable.updatePowerState('someGuid', 4, 2, new Date(), 'someTenantId')).rejects.toThrow( + 'mongo is down' + ) + }) + it('should clear instance status', async () => { collection.updateMany.mockResolvedValue({ modifiedCount: 5 } as any) diff --git a/src/data/mongo/collections/device.ts b/src/data/mongo/collections/device.ts index 9ec1f7747..70934c582 100644 --- a/src/data/mongo/collections/device.ts +++ b/src/data/mongo/collections/device.ts @@ -96,6 +96,21 @@ export class MongoDeviceTable implements IDeviceTable { return this.collection.find({ hostname, tenantId }).toArray() as unknown as WithId[] } + async updatePowerState( + guid: string, + powerState: number, + osPowerSavingState: number, + updatedAt: Date, + tenantId = '' + ): Promise { + const result = await this.collection.updateOne( + { guid, tenantId }, + { $set: { powerState, osPowerSavingState, powerStateUpdatedAt: updatedAt } } + ) + // matchedCount, not modifiedCount: re-writing an unchanged power state is a successful no-op + return result.matchedCount > 0 + } + async clearInstanceStatus(mpsInstance: string): Promise { const result = await this.collection.updateMany( { mpsInstance }, diff --git a/src/data/postgres/tables/device.test.ts b/src/data/postgres/tables/device.test.ts index 49e1a54c9..1e00a4bfb 100644 --- a/src/data/postgres/tables/device.test.ts +++ b/src/data/postgres/tables/device.test.ts @@ -67,7 +67,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tenantid = $3 ORDER BY guid @@ -105,7 +108,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tenantid = $3 ORDER BY guid @@ -139,7 +145,10 @@ describe('device tests', () => { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1`, ['4c4c4544-004b-4210-8033-b6c04f504633'] @@ -202,7 +211,10 @@ describe('device tests', () => { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1 and tenantid = $2`, ['4c4c4544-004b-4210-8033-b6c04f504633', 'tenantId'] @@ -235,7 +247,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE hostname = $1 and tenantid = $2`, ['hostname', 'tenantId'] @@ -259,7 +274,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE hostname = $1 and tenantid = $2`, ['hostname', ''] @@ -317,7 +335,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags @> $1 and tenantId = $4 ORDER BY guid @@ -357,7 +378,10 @@ describe('device tests', () => { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags && $1 and tenantId = $4 ORDER BY guid @@ -671,6 +695,71 @@ describe('device tests', () => { expect(mpsError).toBeInstanceOf(MPSValidationError) }) + test('should leave the power state columns alone when updating a device', async () => { + // both writers of this row must keep disjoint SET lists or one clobbers the other + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) + querySpy.mockResolvedValueOnce({ rows: [{}], command: '', fields: null, rowCount: 1, oid: 0 }) + await deviceTable.update({ guid: '4c4c4544-004b-4210-8033-b6c04f504633', tenantId: '' } as Device) + const sql = querySpy.mock.calls[0][0] + expect(sql).toContain('UPDATE devices') + expect(sql).not.toContain('powerstate') + expect(sql).not.toContain('ospowersavingstate') + expect(sql).not.toContain('powerstateupdatedat') + }) + + test('should get true when power state is updated', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) + const result = await deviceTable.updatePowerState( + '4c4c4544-004b-4210-8033-b6c04f504633', + 4, + 2, + updatedAt, + 'tenantId' + ) + expect(result).toBe(true) + expect(querySpy).toHaveBeenCalledTimes(1) + expect(querySpy).toHaveBeenCalledWith( + ` + UPDATE devices + SET powerstate=$2, ospowersavingstate=$3, powerstateupdatedat=$4 + WHERE guid=$1 and tenantid = $5`, + [ + '4c4c4544-004b-4210-8033-b6c04f504633', + 4, + 2, + updatedAt, + 'tenantId' + ] + ) + }) + + test('should default to an empty tenantId when the power state update omits it', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) + await deviceTable.updatePowerState('4c4c4544-004b-4210-8033-b6c04f504633', 4, 2, updatedAt) + expect(querySpy).toHaveBeenCalledWith(expect.any(String), [ + '4c4c4544-004b-4210-8033-b6c04f504633', + 4, + 2, + updatedAt, + '' + ]) + }) + + test('should get false when no device matches the power state update', async () => { + querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 0, oid: 0 }) + const result = await deviceTable.updatePowerState('4c4c4544-004b-4210-8033-b6c04f504633', 4, 2, new Date()) + expect(result).toBe(false) + }) + + test('should reject when the power state update fails so the caller can back off', async () => { + querySpy.mockRejectedValueOnce(new Error('db is down')) + await expect( + deviceTable.updatePowerState('4c4c4544-004b-4210-8033-b6c04f504633', 4, 2, new Date()) + ).rejects.toThrow('db is down') + }) + test('should get true when device connection status update', async () => { querySpy.mockResolvedValueOnce({ rows: [], command: '', fields: null, rowCount: 1, oid: 0 }) const result = await deviceTable.clearInstanceStatus('localhost') diff --git a/src/data/postgres/tables/device.ts b/src/data/postgres/tables/device.ts index d5bede3a7..54faf5057 100644 --- a/src/data/postgres/tables/device.ts +++ b/src/data/postgres/tables/device.ts @@ -48,7 +48,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tenantid = $3 ORDER BY guid @@ -100,7 +103,10 @@ export class DeviceTable implements IDeviceTable { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1 and tenantid = $2` let params = [id, tenantId] @@ -118,7 +124,10 @@ export class DeviceTable implements IDeviceTable { lastconnected as "lastConnected", lastseen as "lastSeen", lastdisconnected as "lastDisconnected", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE guid = $1` params = [id] @@ -140,7 +149,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE ${columnName} = $1 and tenantid = $2`, [queryValue, tenantId] @@ -178,7 +190,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags @> $1 and tenantId = $4 ORDER BY guid @@ -204,7 +219,10 @@ export class DeviceTable implements IDeviceTable { tenantid as "tenantId", friendlyname as "friendlyName", dnssuffix as "dnsSuffix", - deviceinfo as "deviceInfo" + deviceinfo as "deviceInfo", + powerstate as "powerState", + ospowersavingstate as "osPowerSavingState", + powerstateupdatedat as "powerStateUpdatedAt" FROM devices WHERE tags && $1 and tenantId = $4 ORDER BY guid @@ -307,6 +325,38 @@ export class DeviceTable implements IDeviceTable { } } + /** + * @description Update the cached power state for a device + * @param {string} guid + * @param {number} powerState DMTF power state value read from the device + * @param {number} osPowerSavingState OS power saving state read from the device + * @param {Date} updatedAt time the power state was read + * @param {string} [tenantId] tenant the device belongs to + * @returns {boolean} Return true when a device row matched and was updated + */ + async updatePowerState( + guid: string, + powerState: number, + osPowerSavingState: number, + updatedAt: Date, + tenantId = '' + ): Promise { + const results = await this.db.query( + ` + UPDATE devices + SET powerstate=$2, ospowersavingstate=$3, powerstateupdatedat=$4 + WHERE guid=$1 and tenantid = $5`, + [ + guid, + powerState, + osPowerSavingState, + updatedAt, + tenantId + ] + ) + return results.rowCount > 0 + } + /** * @description Clear the mpsInstance for associated devices before process exit * @param {string} mpsInstance diff --git a/src/interfaces/IDeviceTable.ts b/src/interfaces/IDeviceTable.ts index 9c32712dd..b7a1ef6bf 100644 --- a/src/interfaces/IDeviceTable.ts +++ b/src/interfaces/IDeviceTable.ts @@ -18,5 +18,12 @@ export interface IDeviceTable extends ITable { ) => Promise getByFriendlyName: (hostname: string, tenantId?: string) => Promise getByHostname: (hostname: string, tenantId?: string) => Promise + updatePowerState: ( + guid: string, + powerState: number, + osPowerSavingState: number, + updatedAt: Date, + tenantId?: string + ) => Promise clearInstanceStatus: (mpsInstance: string) => Promise } diff --git a/src/models/models.ts b/src/models/models.ts index 64477b484..c487ecff4 100644 --- a/src/models/models.ts +++ b/src/models/models.ts @@ -22,6 +22,9 @@ export interface Device { lastSeen?: Date lastDisconnected?: Date deviceInfo?: DeviceInfo + powerState?: number + osPowerSavingState?: number + powerStateUpdatedAt?: Date } export interface DeviceInfo { fwVersion: string diff --git a/src/routes/devices/update.test.ts b/src/routes/devices/update.test.ts index 91622192d..0eb7edc62 100644 --- a/src/routes/devices/update.test.ts +++ b/src/routes/devices/update.test.ts @@ -49,6 +49,40 @@ describe('update', () => { expect(endSpy).toHaveBeenCalled() }) + it('should not let the request body overwrite server-owned power state fields', async () => { + const updatedAt = new Date('2026-08-25T17:00:00.000Z') + const stored = { + guid, + friendlyName: 'before', + powerState: 4, + osPowerSavingState: 2, + powerStateUpdatedAt: updatedAt + } as any + const updateFn = vi.fn().mockReturnValue(stored) + const req = { + db: { + devices: { + getById: vi.fn().mockReturnValue(stored), + update: updateFn + } + }, + body: { + guid, + friendlyName: 'after', + powerState: 2, + osPowerSavingState: 0, + powerStateUpdatedAt: new Date('2000-01-01T00:00:00.000Z') + } + } + await updateDevice(req as any, res as any) + const persisted = updateFn.mock.calls[0][0] + expect(persisted.friendlyName).toBe('after') + expect(persisted.powerState).toBe(4) + expect(persisted.osPowerSavingState).toBe(2) + expect(persisted.powerStateUpdatedAt).toBe(updatedAt) + expect(statusSpy).toHaveBeenCalledWith(200) + }) + it('should set status to 200 if getById gets a result', async () => { const device = {} as any diff --git a/src/routes/devices/update.ts b/src/routes/devices/update.ts index 84465924f..4eff8775b 100644 --- a/src/routes/devices/update.ts +++ b/src/routes/devices/update.ts @@ -17,7 +17,9 @@ export async function updateDevice(req: Request, res: Response): Promise { .json({ error: 'NOT FOUND', message: `Device ID ${guid} not found` }) .end() } else { - device = { ...device, ...req.body } + // power state is server-owned, so it never comes from the request body + const { powerState, osPowerSavingState, powerStateUpdatedAt, ...updatable } = req.body + device = { ...device, ...updatable } const results = await req.db.devices.update(device) res.status(200).json(results).end() }