@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. package/vitest.config.ts +0 -38
@@ -1,288 +0,0 @@
1
- /**
2
- * Unit tests for MCP resources
3
- */
4
-
5
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
- import type { McpServer } from '@modelcontextprotocol/server';
7
- import type { HaClient } from '../../src/haClient/index.js';
8
- import { registerAllResources } from '../../src/resources/index.js';
9
-
10
- // Create mock server that captures registered resources
11
- function createMockServer() {
12
- const resources = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
13
- return {
14
- registerResource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler: (...args: unknown[]) => unknown) => {
15
- resources.set(name, { config, handler });
16
- }),
17
- resources,
18
- } as unknown as McpServer & { resources: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
19
- }
20
-
21
- // Create mock HaClient
22
- function createMockClient() {
23
- return {
24
- getStates: vi.fn(),
25
- getState: vi.fn(),
26
- getEntitiesByDomain: vi.fn(),
27
- searchEntities: vi.fn(),
28
- } as unknown as HaClient;
29
- }
30
-
31
- describe('MCP Resources', () => {
32
- let server: ReturnType<typeof createMockServer>;
33
- let client: ReturnType<typeof createMockClient>;
34
-
35
- beforeEach(() => {
36
- server = createMockServer();
37
- client = createMockClient();
38
- vi.clearAllMocks();
39
- });
40
-
41
- describe('Registration', () => {
42
- it('should register all 5 resources', () => {
43
- registerAllResources(server, client);
44
- expect(server.registerResource).toHaveBeenCalledTimes(5);
45
- });
46
-
47
- it('should register All Entities resource', () => {
48
- registerAllResources(server, client);
49
- expect(server.resources.has('All Entities')).toBe(true);
50
- });
51
-
52
- it('should register Entity State resource', () => {
53
- registerAllResources(server, client);
54
- expect(server.resources.has('Entity State')).toBe(true);
55
- });
56
-
57
- it('should register Entity Detailed resource', () => {
58
- registerAllResources(server, client);
59
- expect(server.resources.has('Entity Detailed')).toBe(true);
60
- });
61
-
62
- it('should register Domain Entities resource', () => {
63
- registerAllResources(server, client);
64
- expect(server.resources.has('Domain Entities')).toBe(true);
65
- });
66
-
67
- it('should register Search Entities resource', () => {
68
- registerAllResources(server, client);
69
- expect(server.resources.has('Search Entities')).toBe(true);
70
- });
71
- });
72
-
73
- describe('All Entities Resource', () => {
74
- it('should return entities grouped by domain', async () => {
75
- const mockEntities = [
76
- { entity_id: 'light.living_room', state: 'on', attributes: { friendly_name: 'Living Room Light' } },
77
- { entity_id: 'light.bedroom', state: 'off', attributes: { friendly_name: 'Bedroom Light' } },
78
- { entity_id: 'sensor.temperature', state: '22', attributes: { friendly_name: 'Temperature' } },
79
- ];
80
- (client.getStates as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
81
-
82
- registerAllResources(server, client);
83
- const handler = server.resources.get('All Entities')!.handler;
84
- const result = await handler();
85
-
86
- expect(client.getStates).toHaveBeenCalled();
87
- expect(result.contents).toHaveLength(1);
88
-
89
- const content = JSON.parse(result.contents[0].text);
90
- expect(content.total).toBe(3);
91
- expect(content.domains).toBe(2);
92
- expect(content.entities.light).toHaveLength(2);
93
- expect(content.entities.sensor).toHaveLength(1);
94
- });
95
-
96
- it('should handle empty entity list', async () => {
97
- (client.getStates as ReturnType<typeof vi.fn>).mockResolvedValue([]);
98
-
99
- registerAllResources(server, client);
100
- const handler = server.resources.get('All Entities')!.handler;
101
- const result = await handler();
102
-
103
- const content = JSON.parse(result.contents[0].text);
104
- expect(content.total).toBe(0);
105
- expect(content.domains).toBe(0);
106
- expect(content.entities).toEqual({});
107
- });
108
- });
109
-
110
- describe('Entity State Resource', () => {
111
- it('should return entity state', async () => {
112
- const mockEntity = {
113
- entity_id: 'light.living_room',
114
- state: 'on',
115
- attributes: { friendly_name: 'Living Room Light', brightness: 255 },
116
- last_changed: '2024-01-01T00:00:00Z',
117
- };
118
- (client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntity);
119
-
120
- registerAllResources(server, client);
121
- const handler = server.resources.get('Entity State')!.handler;
122
- const uri = new URL('hass://entities/light.living_room');
123
- const result = await handler(uri, { entity_id: 'light.living_room' });
124
-
125
- expect(client.getState).toHaveBeenCalledWith('light.living_room');
126
- expect(result.contents).toHaveLength(1);
127
-
128
- const content = JSON.parse(result.contents[0].text);
129
- expect(content.entity_id).toBe('light.living_room');
130
- expect(content.state).toBe('on');
131
- expect(content.friendly_name).toBe('Living Room Light');
132
- });
133
-
134
- it('should return error for non-existent entity', async () => {
135
- (client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(null);
136
-
137
- registerAllResources(server, client);
138
- const handler = server.resources.get('Entity State')!.handler;
139
- const uri = new URL('hass://entities/light.nonexistent');
140
- const result = await handler(uri, { entity_id: 'light.nonexistent' });
141
-
142
- const content = JSON.parse(result.contents[0].text);
143
- expect(content.error).toBe('Entity not found');
144
- expect(content.entity_id).toBe('light.nonexistent');
145
- });
146
- });
147
-
148
- describe('Entity Detailed Resource', () => {
149
- it('should return detailed entity info', async () => {
150
- const mockEntity = {
151
- entity_id: 'light.living_room',
152
- state: 'on',
153
- attributes: { friendly_name: 'Living Room Light', brightness: 255, supported_features: 1 },
154
- last_changed: '2024-01-01T00:00:00Z',
155
- last_updated: '2024-01-01T00:00:01Z',
156
- context: { id: 'abc123', parent_id: null, user_id: null },
157
- };
158
- (client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntity);
159
-
160
- registerAllResources(server, client);
161
- const handler = server.resources.get('Entity Detailed')!.handler;
162
- const uri = new URL('hass://entities/light.living_room/detailed');
163
- const result = await handler(uri, { entity_id: 'light.living_room' });
164
-
165
- const content = JSON.parse(result.contents[0].text);
166
- expect(content.entity_id).toBe('light.living_room');
167
- expect(content.attributes).toHaveProperty('brightness');
168
- expect(content.attributes).toHaveProperty('supported_features');
169
- expect(content.last_updated).toBeDefined();
170
- expect(content.context).toBeDefined();
171
- });
172
-
173
- it('should return error for non-existent entity', async () => {
174
- (client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(null);
175
-
176
- registerAllResources(server, client);
177
- const handler = server.resources.get('Entity Detailed')!.handler;
178
- const uri = new URL('hass://entities/light.nonexistent/detailed');
179
- const result = await handler(uri, { entity_id: 'light.nonexistent' });
180
-
181
- const content = JSON.parse(result.contents[0].text);
182
- expect(content.error).toBe('Entity not found');
183
- });
184
- });
185
-
186
- describe('Domain Entities Resource', () => {
187
- it('should return all entities for a domain', async () => {
188
- const mockEntities = [
189
- { entity_id: 'light.living_room', state: 'on', attributes: { friendly_name: 'Living Room' } },
190
- { entity_id: 'light.bedroom', state: 'off', attributes: { friendly_name: 'Bedroom' } },
191
- ];
192
- (client.getEntitiesByDomain as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
193
-
194
- registerAllResources(server, client);
195
- const handler = server.resources.get('Domain Entities')!.handler;
196
- const uri = new URL('hass://entities/domain/light');
197
- const result = await handler(uri, { domain: 'light' });
198
-
199
- expect(client.getEntitiesByDomain).toHaveBeenCalledWith('light');
200
-
201
- const content = JSON.parse(result.contents[0].text);
202
- expect(content.domain).toBe('light');
203
- expect(content.count).toBe(2);
204
- expect(content.entities).toHaveLength(2);
205
- });
206
-
207
- it('should handle domain with no entities', async () => {
208
- (client.getEntitiesByDomain as ReturnType<typeof vi.fn>).mockResolvedValue([]);
209
-
210
- registerAllResources(server, client);
211
- const handler = server.resources.get('Domain Entities')!.handler;
212
- const uri = new URL('hass://entities/domain/vacuum');
213
- const result = await handler(uri, { domain: 'vacuum' });
214
-
215
- const content = JSON.parse(result.contents[0].text);
216
- expect(content.domain).toBe('vacuum');
217
- expect(content.count).toBe(0);
218
- expect(content.entities).toEqual([]);
219
- });
220
- });
221
-
222
- describe('Search Entities Resource', () => {
223
- it('should search entities with query and limit', async () => {
224
- const mockEntities = [
225
- { entity_id: 'light.living_room', state: 'on', attributes: { friendly_name: 'Living Room Light' } },
226
- { entity_id: 'light.living_lamp', state: 'off', attributes: { friendly_name: 'Living Lamp' } },
227
- ];
228
- (client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
229
-
230
- registerAllResources(server, client);
231
- const handler = server.resources.get('Search Entities')!.handler;
232
- const uri = new URL('hass://search/living/10');
233
- const result = await handler(uri, { query: 'living', limit: '10' });
234
-
235
- expect(client.searchEntities).toHaveBeenCalledWith('living');
236
-
237
- const content = JSON.parse(result.contents[0].text);
238
- expect(content.query).toBe('living');
239
- expect(content.limit).toBe(10);
240
- expect(content.count).toBe(2);
241
- });
242
-
243
- it('should apply limit to search results', async () => {
244
- const mockEntities = Array.from({ length: 100 }, (_, i) => ({
245
- entity_id: `sensor.test_${i}`,
246
- state: 'on',
247
- attributes: { friendly_name: `Test ${i}` },
248
- }));
249
- (client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
250
-
251
- registerAllResources(server, client);
252
- const handler = server.resources.get('Search Entities')!.handler;
253
- const uri = new URL('hass://search/test/5');
254
- const result = await handler(uri, { query: 'test', limit: '5' });
255
-
256
- const content = JSON.parse(result.contents[0].text);
257
- expect(content.count).toBe(5);
258
- expect(content.entities).toHaveLength(5);
259
- });
260
-
261
- it('should decode URL-encoded query', async () => {
262
- (client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue([]);
263
-
264
- registerAllResources(server, client);
265
- const handler = server.resources.get('Search Entities')!.handler;
266
- const uri = new URL('hass://search/living%20room/50');
267
- const result = await handler(uri, { query: 'living%20room', limit: '50' });
268
-
269
- expect(client.searchEntities).toHaveBeenCalledWith('living room');
270
-
271
- const content = JSON.parse(result.contents[0].text);
272
- expect(content.query).toBe('living room');
273
- });
274
-
275
- it('should use default limit when not specified', async () => {
276
- (client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue([]);
277
-
278
- registerAllResources(server, client);
279
- const handler = server.resources.get('Search Entities')!.handler;
280
- const uri = new URL('hass://search/test/50');
281
- // Simulate undefined limit
282
- const result = await handler(uri, { query: 'test', limit: undefined });
283
-
284
- const content = JSON.parse(result.contents[0].text);
285
- expect(content.limit).toBe(50);
286
- });
287
- });
288
- });
@@ -1,44 +0,0 @@
1
- /**
2
- * Output contract of scripts/generate-jwt.ts: stdout is exactly one bare JWT.
3
- *
4
- * Callers capture it with `TOKEN=$(npm run -s generate:jwt -- ...)`. A banner or a
5
- * second copy of the token on stdout (the pre-1.7.5 behavior) corrupts that capture,
6
- * so this test runs the real script as a subprocess and checks both streams.
7
- */
8
-
9
- import { describe, it, expect } from 'vitest';
10
- import { spawnSync } from 'node:child_process';
11
- import { resolve } from 'node:path';
12
- import { verifyJwt } from '../../src/utils/jwt.js';
13
-
14
- const ROOT = resolve(__dirname, '../..');
15
- const TSX_CLI = resolve(ROOT, 'node_modules/tsx/dist/cli.mjs');
16
- const SCRIPT = resolve(ROOT, 'scripts/generate-jwt.ts');
17
- const SECRET = 'dummy-secret-for-generate-jwt-test-0123456789';
18
-
19
- function run(args: string[]): { status: number | null; stdout: string; stderr: string } {
20
- const result = spawnSync(process.execPath, [TSX_CLI, SCRIPT, ...args], {
21
- cwd: ROOT,
22
- // Never inherit a real secret from the developer's environment.
23
- env: { ...process.env, MCP_AUTH_SECRET: '' },
24
- encoding: 'utf8',
25
- });
26
- return { status: result.status, stdout: result.stdout, stderr: result.stderr };
27
- }
28
-
29
- describe('generate-jwt output contract', () => {
30
- it('prints exactly one valid JWT on stdout; details go to stderr', () => {
31
- const { status, stdout, stderr } = run(['--sub', 'contract-test', '--exp', '5m', '--secret', SECRET]);
32
- expect(status).toBe(0);
33
-
34
- const lines = stdout.split(/\r?\n/).filter((l) => l.trim() !== '');
35
- expect(lines).toHaveLength(1);
36
-
37
- const result = verifyJwt(lines[0], SECRET);
38
- expect(result.valid).toBe(true);
39
- expect(result.payload?.sub).toBe('contract-test');
40
-
41
- expect(stderr).toContain('Subject: contract-test');
42
- expect(stderr).not.toContain(lines[0]);
43
- }, 30_000);
44
- });
@@ -1,223 +0,0 @@
1
- /**
2
- * SEC-AC — Access Control / Authorization
3
- * OWASP A01:2021 (Broken Access Control), API1/API5:2023 (BOLA/BFLA)
4
- *
5
- * Findings covered:
6
- * H1 — REST /api/* routes bypass RBAC (authenticated but no permission check).
7
- *
8
- * These tests assert the SECURE behavior: every REST route is gated on the same
9
- * Permission.* bit as its MCP-tool twin, and a token lacking that bit is rejected
10
- * with 403 BEFORE any Home Assistant call is made.
11
- */
12
-
13
- import { describe, it, expect, vi } from 'vitest';
14
- import type { IncomingMessage, ServerResponse } from 'node:http';
15
- import type { HaClient } from '../../src/haClient/index.js';
16
- import { Permission } from '../../src/permissions/index.js';
17
- import { requiredRestPermission, handleRestApi } from '../../src/server/http.js';
18
- import { handleEventSubscription, getClientCount } from '../../src/server/eventSubscription.js';
19
- import type { EventSubscriber } from '../../src/haClient/events.js';
20
- import type { AuthenticatedRequest } from '../../src/server/auth.js';
21
-
22
- /** Build a mock request carrying a permission mask in auth.extra. */
23
- function mockReq(
24
- method: string,
25
- url: string,
26
- permissions: number,
27
- body?: unknown
28
- ): AuthenticatedRequest {
29
- const listeners: Record<string, ((arg?: unknown) => void)[]> = {};
30
- const req = {
31
- method,
32
- url,
33
- headers: { host: 'mcp.test:3000' },
34
- auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
35
- on(event: string, cb: (arg?: unknown) => void) {
36
- (listeners[event] ??= []).push(cb);
37
- // Drive the body stream synchronously on the next tick for POST parseBody().
38
- if (event === 'end') {
39
- queueMicrotask(() => {
40
- if (body !== undefined) {
41
- for (const d of listeners['data'] ?? []) d(Buffer.from(JSON.stringify(body)));
42
- }
43
- for (const e of listeners['end'] ?? []) e();
44
- });
45
- }
46
- return req;
47
- },
48
- } as unknown as AuthenticatedRequest;
49
- return req;
50
- }
51
-
52
- /** Capture status code and JSON body written to the response. */
53
- function mockRes(): ServerResponse & { _status?: number; _body?: unknown } {
54
- const res = {
55
- statusCode: 200,
56
- headersSent: false,
57
- setHeader: vi.fn(),
58
- end: vi.fn(function (this: { _body?: unknown }, payload?: unknown) {
59
- if (typeof payload === 'string') {
60
- try {
61
- this._body = JSON.parse(payload);
62
- } catch {
63
- this._body = payload;
64
- }
65
- }
66
- return this;
67
- }),
68
- } as unknown as ServerResponse & { _status?: number; _body?: unknown };
69
- // mirror statusCode into _status when sendJson sets it
70
- Object.defineProperty(res, '_status', {
71
- get() {
72
- return res.statusCode;
73
- },
74
- configurable: true,
75
- });
76
- return res;
77
- }
78
-
79
- describe('SEC-AC — Access Control', () => {
80
- describe('H1: REST route → permission mapping (mirrors MCP-tool twins)', () => {
81
- it('maps read routes to QUERY', () => {
82
- expect(requiredRestPermission('GET', '/api/states')).toBe(Permission.QUERY);
83
- expect(requiredRestPermission('GET', '/api/states/light.kitchen')).toBe(Permission.QUERY);
84
- expect(requiredRestPermission('GET', '/api/sensors')).toBe(Permission.QUERY);
85
- expect(requiredRestPermission('GET', '/api/entities/light')).toBe(Permission.QUERY);
86
- expect(requiredRestPermission('GET', '/api/search')).toBe(Permission.QUERY);
87
- expect(requiredRestPermission('GET', '/api/history/sensor.temp')).toBe(Permission.QUERY);
88
- expect(requiredRestPermission('GET', '/api/version')).toBe(Permission.QUERY);
89
- });
90
-
91
- it('maps the service-call route to CONTROL', () => {
92
- expect(requiredRestPermission('POST', '/api/services/light/turn_on')).toBe(Permission.CONTROL);
93
- });
94
-
95
- it('returns null for unknown routes (fall through to 404, no auth needed)', () => {
96
- expect(requiredRestPermission('GET', '/api/unknown')).toBeNull();
97
- expect(requiredRestPermission('DELETE', '/api/states')).toBeNull();
98
- });
99
- });
100
-
101
- describe('H1: enforcement — insufficient permission is rejected with 403', () => {
102
- it('blocks GET /api/states without QUERY and never calls the client', async () => {
103
- const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
104
- const req = mockReq('GET', '/api/states', Permission.CONTROL); // CONTROL, not QUERY
105
- const res = mockRes();
106
-
107
- await handleRestApi(req, res, '/api/states', client);
108
-
109
- expect(res.statusCode).toBe(403);
110
- expect(client.getStates).not.toHaveBeenCalled();
111
- });
112
-
113
- it('blocks POST /api/services without CONTROL and never calls the service', async () => {
114
- const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
115
- const req = mockReq('POST', '/api/services/light/turn_on', Permission.QUERY, {
116
- entity_id: 'light.kitchen',
117
- });
118
- const res = mockRes();
119
-
120
- await handleRestApi(req, res, '/api/services/light/turn_on', client);
121
-
122
- expect(res.statusCode).toBe(403);
123
- expect(client.callService).not.toHaveBeenCalled();
124
- });
125
- });
126
-
127
- describe('H1: enforcement — sufficient permission is allowed', () => {
128
- it('allows GET /api/states with QUERY', async () => {
129
- const client = { getStates: vi.fn().mockResolvedValue([{ entity_id: 'light.x' }]) } as unknown as HaClient;
130
- const req = mockReq('GET', '/api/states', Permission.QUERY);
131
- const res = mockRes();
132
-
133
- await handleRestApi(req, res, '/api/states', client);
134
-
135
- expect(res.statusCode).toBe(200);
136
- expect(client.getStates).toHaveBeenCalledOnce();
137
- });
138
-
139
- it('allows POST /api/services with CONTROL', async () => {
140
- const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
141
- const req = mockReq('POST', '/api/services/light/turn_on', Permission.CONTROL, {
142
- entity_id: 'light.kitchen',
143
- });
144
- const res = mockRes();
145
-
146
- await handleRestApi(req, res, '/api/services/light/turn_on', client);
147
-
148
- expect(res.statusCode).toBe(200);
149
- expect(client.callService).toHaveBeenCalledOnce();
150
- });
151
-
152
- it('treats SUPERUSER (0xFF) as allowed everywhere', async () => {
153
- const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
154
- const req = mockReq('GET', '/api/states', 0xff);
155
- const res = mockRes();
156
-
157
- await handleRestApi(req, res, '/api/states', client);
158
-
159
- expect(res.statusCode).toBe(200);
160
- });
161
- });
162
-
163
- describe('L2: SSE /subscribe_events requires QUERY permission', () => {
164
- /** Minimal SSE-capable response mock. */
165
- function sseRes() {
166
- return {
167
- writableEnded: false,
168
- writeHead: vi.fn(),
169
- write: vi.fn(),
170
- end: vi.fn(),
171
- } as unknown as ServerResponse & { writeHead: ReturnType<typeof vi.fn> };
172
- }
173
-
174
- /** Request mock that captures the 'close' handler so we can tear down timers. */
175
- function sseReq(permissions: number) {
176
- const handlers: Record<string, (arg?: unknown) => void> = {};
177
- const req = {
178
- url: '/subscribe_events',
179
- headers: {},
180
- auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
181
- on(event: string, cb: (arg?: unknown) => void) {
182
- handlers[event] = cb;
183
- return req;
184
- },
185
- } as unknown as AuthenticatedRequest;
186
- return { req, handlers };
187
- }
188
-
189
- it('rejects a subscriber lacking QUERY with 403 and creates no client', async () => {
190
- const before = getClientCount();
191
- const { req } = sseReq(Permission.CONTROL); // CONTROL, not QUERY
192
- const res = sseRes();
193
- const subscriber = { isConnected: vi.fn() } as unknown as EventSubscriber;
194
-
195
- await handleEventSubscription(req, res, subscriber);
196
-
197
- expect(res.writeHead).toHaveBeenCalledWith(403, expect.anything());
198
- expect(getClientCount()).toBe(before); // no leak
199
- expect(subscriber.isConnected).not.toHaveBeenCalled();
200
- });
201
-
202
- it('allows a subscriber with QUERY to open the stream', async () => {
203
- const { req, handlers } = sseReq(Permission.QUERY);
204
- const res = sseRes();
205
- const subscriber = {
206
- isConnected: vi.fn().mockReturnValue(true),
207
- subscribeEventType: vi.fn().mockResolvedValue('sub-1'),
208
- unsubscribe: vi.fn().mockResolvedValue(undefined),
209
- } as unknown as EventSubscriber;
210
-
211
- await handleEventSubscription(req, res, subscriber);
212
-
213
- // 200 stream opened, not 403
214
- expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
215
- 'Content-Type': 'text/event-stream',
216
- }));
217
- expect(subscriber.subscribeEventType).toHaveBeenCalled();
218
-
219
- // tear down the keep-alive interval the handler registered
220
- handlers['close']?.();
221
- });
222
- });
223
- });
@@ -1,117 +0,0 @@
1
- /**
2
- * SEC-AUTHN — Authentication & Session
3
- * OWASP A07:2021 (Identification & Authentication Failures), API2:2023
4
- *
5
- * Findings covered:
6
- * H3 — tokens without `exp` never expire; expiry is optional and unenforceable.
7
- * M8 — JWT secret strength is not enforced at startup.
8
- * L6 — no `iss` / `aud` / `nbf` / clock-skew validation.
9
- *
10
- * Secure behavior asserted here:
11
- * - verifyJwt can REQUIRE exp (opt-in), validate nbf, and check iss/aud when configured.
12
- * - The default (no options) stays backward-compatible: a token without exp is accepted.
13
- * - loadConfig refuses a bearer secret shorter than 32 chars.
14
- */
15
-
16
- import { describe, it, expect } from 'vitest';
17
- import { createJwt, verifyJwt } from '../../src/utils/jwt.js';
18
- import { loadConfig } from '../../src/config.js';
19
-
20
- const SECRET = 'test-secret-key-32chars-minimum!';
21
- const now = () => Math.floor(Date.now() / 1000);
22
-
23
- describe('SEC-AUTHN — Authentication', () => {
24
- describe('H3: enforce token expiry (opt-in via requireExp)', () => {
25
- it('rejects a token without exp when requireExp is set', () => {
26
- const token = createJwt({ sub: 'service' }, SECRET);
27
- const result = verifyJwt(token, SECRET, { requireExp: true });
28
- expect(result.valid).toBe(false);
29
- expect(result.error).toMatch(/exp/i);
30
- });
31
-
32
- it('accepts a token with a future exp when requireExp is set', () => {
33
- const token = createJwt({ sub: 'u', exp: now() + 3600 }, SECRET);
34
- expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(true);
35
- });
36
-
37
- it('still rejects an expired token when requireExp is set', () => {
38
- const token = createJwt({ sub: 'u', exp: now() - 100 }, SECRET);
39
- expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(false);
40
- });
41
-
42
- it('default (no options) remains backward-compatible: accepts no-exp token', () => {
43
- const token = createJwt({ sub: 'service' }, SECRET);
44
- expect(verifyJwt(token, SECRET).valid).toBe(true);
45
- });
46
- });
47
-
48
- describe('L6: nbf (not-before) validation', () => {
49
- it('rejects a token whose nbf is in the future', () => {
50
- const token = createJwt({ sub: 'u', nbf: now() + 3600 }, SECRET);
51
- const result = verifyJwt(token, SECRET);
52
- expect(result.valid).toBe(false);
53
- expect(result.error).toMatch(/not yet valid|nbf/i);
54
- });
55
-
56
- it('accepts a token whose nbf is in the past', () => {
57
- const token = createJwt({ sub: 'u', nbf: now() - 100 }, SECRET);
58
- expect(verifyJwt(token, SECRET).valid).toBe(true);
59
- });
60
-
61
- it('tolerates small clock skew on nbf', () => {
62
- const token = createJwt({ sub: 'u', nbf: now() + 30 }, SECRET);
63
- expect(verifyJwt(token, SECRET, { clockSkewSec: 60 }).valid).toBe(true);
64
- });
65
- });
66
-
67
- describe('L6: issuer / audience validation (only when configured)', () => {
68
- it('rejects a token with the wrong issuer', () => {
69
- const token = createJwt({ sub: 'u', iss: 'evil' }, SECRET);
70
- const result = verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' });
71
- expect(result.valid).toBe(false);
72
- expect(result.error).toMatch(/issuer/i);
73
- });
74
-
75
- it('accepts a token with the expected issuer', () => {
76
- const token = createJwt({ sub: 'u', iss: 'mcp-ha-connect' }, SECRET);
77
- expect(verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' }).valid).toBe(true);
78
- });
79
-
80
- it('rejects a token whose audience does not match', () => {
81
- const token = createJwt({ sub: 'u', aud: 'other' }, SECRET);
82
- const result = verifyJwt(token, SECRET, { audience: 'mcp' });
83
- expect(result.valid).toBe(false);
84
- expect(result.error).toMatch(/audience/i);
85
- });
86
-
87
- it('accepts a token whose audience array contains the expected value', () => {
88
- const token = createJwt({ sub: 'u', aud: ['mcp', 'other'] }, SECRET);
89
- expect(verifyJwt(token, SECRET, { audience: 'mcp' }).valid).toBe(true);
90
- });
91
-
92
- it('ignores iss/aud when not configured (backward-compatible)', () => {
93
- const token = createJwt({ sub: 'u', iss: 'whatever', aud: 'whatever' }, SECRET);
94
- expect(verifyJwt(token, SECRET).valid).toBe(true);
95
- });
96
- });
97
-
98
- describe('M8: bearer secret strength enforced at startup', () => {
99
- const base = { MCP_SERVER_USE_HTTP: 'true', MCP_AUTH_METHOD: 'bearer' };
100
-
101
- it('rejects a bearer secret shorter than 32 characters', () => {
102
- expect(() =>
103
- loadConfig({ ...base, MCP_AUTH_SECRET: 'too-short' } as NodeJS.ProcessEnv)
104
- ).toThrow(/32|secret/i);
105
- });
106
-
107
- it('accepts a bearer secret of at least 32 characters', () => {
108
- expect(() =>
109
- loadConfig({ ...base, MCP_AUTH_SECRET: 'a'.repeat(32) } as NodeJS.ProcessEnv)
110
- ).not.toThrow();
111
- });
112
-
113
- it('does not require a secret when auth method is none', () => {
114
- expect(() => loadConfig({ MCP_AUTH_METHOD: 'none' } as NodeJS.ProcessEnv)).not.toThrow();
115
- });
116
- });
117
- });