fix(HomeAssistant Node): Only sent Home Assistant attributes on upsert if set (#36298)

This commit is contained in:
Florian Gareis 2026-10-14 08:58:22 +00:00 committed by GitHub
parent e47940eecd
commit b8780d8fee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 255 additions and 8 deletions

View File

@ -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;
}
}

View File

@ -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<IExecuteFunctions>;
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<IExecuteFunctions>({
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();
});
});
});