From b8780d8fee5e445a12d3583041a1e09a36213254 Mon Sep 17 00:00:00 2001 From: Florian Gareis Date: Wed, 14 Oct 2026 08:58:22 +0000 Subject: [PATCH] fix(HomeAssistant Node): Only sent Home Assistant attributes on upsert if set (#36298) --- .../nodes/HomeAssistant/HomeAssistant.node.ts | 31 ++- .../test/HomeAssistant.node.test.ts | 232 ++++++++++++++++++ 2 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 packages/nodes-base/nodes/HomeAssistant/test/HomeAssistant.node.test.ts diff --git a/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts b/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts index 8f330554018..451a231e468 100644 --- a/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts +++ b/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts @@ -9,7 +9,9 @@ import { type INodePropertyOptions, type INodeType, type INodeTypeDescription, + NodeApiError, NodeConnectionTypes, + setSafeObjectProperty, } from 'n8n-workflow'; import { cameraProxyFields, cameraProxyOperations } from './CameraProxyDescription'; @@ -252,17 +254,30 @@ export class HomeAssistant implements INodeType { attributes: IDataObject[]; }; - const body = { + const body: { state: string; attributes?: IDataObject } = { state, - attributes: {}, }; - if (Object.entries(stateAttributes).length) { - if (stateAttributes.attributes !== undefined) { - stateAttributes.attributes.map((attribute) => { - // @ts-ignore - body.attributes[attribute.name as string] = attribute.value; - }); + if (stateAttributes.attributes?.length) { + const attributes: IDataObject = {}; + stateAttributes.attributes.forEach((attribute) => { + setSafeObjectProperty(attributes, attribute.name as string, attribute.value); + }); + body.attributes = attributes; + } else { + // Home Assistant replaces all attributes on upsert, so keep the existing ones + try { + const currentState = (await homeAssistantApiRequest.call( + this, + 'GET', + `/states/${entityId}`, + )) as IDataObject; + if (currentState?.attributes) { + body.attributes = currentState.attributes as IDataObject; + } + } catch (error) { + // The entity does not exist yet, so there are no attributes to keep + if (!(error instanceof NodeApiError && error.httpCode === '404')) throw error; } } diff --git a/packages/nodes-base/nodes/HomeAssistant/test/HomeAssistant.node.test.ts b/packages/nodes-base/nodes/HomeAssistant/test/HomeAssistant.node.test.ts new file mode 100644 index 00000000000..4458e7af934 --- /dev/null +++ b/packages/nodes-base/nodes/HomeAssistant/test/HomeAssistant.node.test.ts @@ -0,0 +1,232 @@ +import type { MockProxy } from 'vitest-mock-extended'; +import { mock } from 'vitest-mock-extended'; +import type { + IDataObject, + IExecuteFunctions, + INode, + INodeExecutionData, + IPairedItemData, +} from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import * as GenericFunctions from '../GenericFunctions'; +import { HomeAssistant } from '../HomeAssistant.node'; +import type { MockedFunction } from 'vitest'; + +vi.mock('../GenericFunctions'); + +const attribute = (name: string, value: IDataObject[keyof IDataObject]): IDataObject => ({ + name, + value, +}); + +describe('HomeAssistant Node', () => { + let homeAssistant: HomeAssistant; + let mockExecuteFunctions: MockProxy; + + const mockHomeAssistantApiRequest = GenericFunctions.homeAssistantApiRequest as MockedFunction< + typeof GenericFunctions.homeAssistantApiRequest + >; + + const mockNode: INode = { + id: 'test-node-id', + name: 'Home Assistant', + type: 'n8n-nodes-base.homeAssistant', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }; + + beforeEach(() => { + homeAssistant = new HomeAssistant(); + mockExecuteFunctions = mock({ + helpers: { + constructExecutionMetaData: vi.fn( + ( + data: INodeExecutionData[], + options: { itemData: IPairedItemData | IPairedItemData[] }, + ) => { + const itemIndex = + (Array.isArray(options?.itemData) + ? options.itemData[0]?.item + : options?.itemData?.item) ?? 0; + return data.map((item) => ({ ...item, pairedItem: { item: itemIndex } })); + }, + ), + returnJsonArray: vi.fn((data: IDataObject | IDataObject[]) => + Array.isArray(data) ? data.map((d) => ({ json: d })) : [{ json: data }], + ), + }, + }); + + vi.clearAllMocks(); + + mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); + mockExecuteFunctions.getNode.mockReturnValue(mockNode); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + }); + + describe('State resource - upsert', () => { + const noAttributesParams: IDataObject = { + resource: 'state', + operation: 'upsert', + entityId: 'sensor.test', + state: 'off', + stateAttributes: {}, + }; + + it('should keep the existing attributes when no attributes are set', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (paramName: string) => noAttributesParams[paramName], + ); + + const existingAttributes = { unit_of_measurement: '°C', friendly_name: 'Test Sensor' }; + mockHomeAssistantApiRequest + .mockResolvedValueOnce({ + entity_id: 'sensor.test', + state: 'on', + attributes: existingAttributes, + }) + .mockResolvedValueOnce({ entity_id: 'sensor.test', state: 'off' }); + + await homeAssistant.execute.call(mockExecuteFunctions); + + expect(mockHomeAssistantApiRequest).toHaveBeenNthCalledWith(1, 'GET', '/states/sensor.test'); + expect(mockHomeAssistantApiRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/states/sensor.test', + { + state: 'off', + attributes: existingAttributes, + }, + ); + }); + + it('should keep the existing attributes when the attribute list is empty', async () => { + // Adding attribute rows and deleting them again stores an empty list + mockExecuteFunctions.getNodeParameter.mockImplementation( + (paramName: string) => + ({ ...noAttributesParams, stateAttributes: { attributes: [] } })[paramName], + ); + + const existingAttributes = { unit_of_measurement: '°C', friendly_name: 'Test Sensor' }; + mockHomeAssistantApiRequest + .mockResolvedValueOnce({ + entity_id: 'sensor.test', + state: 'on', + attributes: existingAttributes, + }) + .mockResolvedValueOnce({ entity_id: 'sensor.test', state: 'off' }); + + await homeAssistant.execute.call(mockExecuteFunctions); + + expect(mockHomeAssistantApiRequest).toHaveBeenNthCalledWith(1, 'GET', '/states/sensor.test'); + expect(mockHomeAssistantApiRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/states/sensor.test', + { + state: 'off', + attributes: existingAttributes, + }, + ); + }); + + it('should not send attributes when the entity does not exist yet', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (paramName: string) => noAttributesParams[paramName], + ); + + mockHomeAssistantApiRequest + .mockRejectedValueOnce(new NodeApiError(mockNode, { statusCode: 404 })) + .mockResolvedValueOnce({ entity_id: 'sensor.test', state: 'off' }); + + await homeAssistant.execute.call(mockExecuteFunctions); + + expect(mockHomeAssistantApiRequest).toHaveBeenNthCalledWith( + 2, + 'POST', + '/states/sensor.test', + { + state: 'off', + }, + ); + }); + + it('should rethrow errors other than 404 when reading the current state', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation( + (paramName: string) => noAttributesParams[paramName], + ); + + mockHomeAssistantApiRequest.mockRejectedValueOnce( + new NodeApiError(mockNode, { statusCode: 401 }), + ); + + await expect(homeAssistant.execute.call(mockExecuteFunctions)).rejects.toThrow(NodeApiError); + expect(mockHomeAssistantApiRequest).toHaveBeenCalledTimes(1); + }); + + it('should send the provided attributes when they are set', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => { + const params: IDataObject = { + resource: 'state', + operation: 'upsert', + entityId: 'sensor.test', + state: 'on', + stateAttributes: { + attributes: [ + attribute('unit_of_measurement', '°C'), + attribute('friendly_name', 'Test Sensor'), + ], + }, + }; + return params[paramName]; + }); + + mockHomeAssistantApiRequest.mockResolvedValue({ entity_id: 'sensor.test', state: 'on' }); + + await homeAssistant.execute.call(mockExecuteFunctions); + + expect(mockHomeAssistantApiRequest).toHaveBeenCalledWith('POST', '/states/sensor.test', { + state: 'on', + attributes: { + unit_of_measurement: '°C', + friendly_name: 'Test Sensor', + }, + }); + }); + + it('should not write unsafe prototype-polluting attribute keys', async () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => { + const params: IDataObject = { + resource: 'state', + operation: 'upsert', + entityId: 'sensor.test', + state: 'on', + stateAttributes: { + attributes: [ + attribute('__proto__', { polluted: true }), + attribute('friendly_name', 'Test Sensor'), + ], + }, + }; + return params[paramName]; + }); + + mockHomeAssistantApiRequest.mockResolvedValue({ entity_id: 'sensor.test', state: 'on' }); + + await homeAssistant.execute.call(mockExecuteFunctions); + + const sentBody = mockHomeAssistantApiRequest.mock.calls[0][2] as { + attributes: IDataObject; + }; + // The unsafe key must be skipped, and safe keys still written. + expect(Object.prototype.hasOwnProperty.call(sentBody.attributes, '__proto__')).toBe(false); + expect(sentBody.attributes).toEqual({ friendly_name: 'Test Sensor' }); + // An unsafe assignment would reparent the object's prototype; the safe write leaves it intact. + expect(Object.getPrototypeOf(sentBody.attributes)).toBe(Object.prototype); + expect((sentBody.attributes as IDataObject).polluted).toBeUndefined(); + }); + }); +});