@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.4.0

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 (293) hide show
  1. package/.env.example +1 -1
  2. package/README.md +24 -2
  3. package/dist/omadaClient/accessControl.d.ts +290 -0
  4. package/dist/omadaClient/accessControl.js +509 -0
  5. package/dist/omadaClient/accessControl.js.map +1 -0
  6. package/dist/omadaClient/client.d.ts +6 -1
  7. package/dist/omadaClient/client.js +12 -1
  8. package/dist/omadaClient/client.js.map +1 -1
  9. package/dist/omadaClient/diagnose.d.ts +68 -0
  10. package/dist/omadaClient/diagnose.js +164 -0
  11. package/dist/omadaClient/diagnose.js.map +1 -0
  12. package/dist/omadaClient/index.d.ts +9 -0
  13. package/dist/omadaClient/index.js +19 -0
  14. package/dist/omadaClient/index.js.map +1 -1
  15. package/dist/omadaClient/macGroups.d.ts +58 -0
  16. package/dist/omadaClient/macGroups.js +110 -0
  17. package/dist/omadaClient/macGroups.js.map +1 -0
  18. package/dist/omadaClient/serialize.d.ts +13 -0
  19. package/dist/omadaClient/serialize.js +29 -0
  20. package/dist/omadaClient/serialize.js.map +1 -0
  21. package/dist/omadaClient/timeRange.d.ts +41 -0
  22. package/dist/omadaClient/timeRange.js +109 -0
  23. package/dist/omadaClient/timeRange.js.map +1 -0
  24. package/dist/tools/infra/actions.d.ts +1 -1
  25. package/dist/tools/infra/actions.js +1 -1
  26. package/dist/tools/omada/accessControl.d.ts +151 -0
  27. package/dist/tools/omada/accessControl.js +158 -0
  28. package/dist/tools/omada/accessControl.js.map +1 -0
  29. package/dist/tools/omada/index.js +9 -0
  30. package/dist/tools/omada/index.js.map +1 -1
  31. package/dist/tools/omada/macGroups.d.ts +19 -0
  32. package/dist/tools/omada/macGroups.js +77 -0
  33. package/dist/tools/omada/macGroups.js.map +1 -0
  34. package/dist/tools/omada/namespace.js +16 -0
  35. package/dist/tools/omada/namespace.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +5 -1
  39. package/.codex/review-rules.md +0 -16
  40. package/.dockerignore +0 -8
  41. package/.gitattributes +0 -30
  42. package/.github/workflows/ci.yml +0 -193
  43. package/AGENTS.md +0 -389
  44. package/Dockerfile +0 -50
  45. package/docker-compose.yml +0 -23
  46. package/docs/CHANGELOG.md +0 -341
  47. package/docs/COVERAGE.md +0 -219
  48. package/docs/QUICK_START.md +0 -240
  49. package/eslint.config.js +0 -33
  50. package/n8n/ai-agent-safety-monitor.json +0 -355
  51. package/openapi.json +0 -1259
  52. package/scripts/check-version-sync.mjs +0 -69
  53. package/scripts/generate-jwt.ts +0 -121
  54. package/src/config.ts +0 -440
  55. package/src/haClient/automations.ts +0 -261
  56. package/src/haClient/calendars.ts +0 -94
  57. package/src/haClient/config.ts +0 -52
  58. package/src/haClient/devices.ts +0 -653
  59. package/src/haClient/entities.ts +0 -128
  60. package/src/haClient/events.ts +0 -689
  61. package/src/haClient/history.ts +0 -73
  62. package/src/haClient/index.ts +0 -336
  63. package/src/haClient/request.ts +0 -142
  64. package/src/haClient/services.ts +0 -45
  65. package/src/haClient/states.ts +0 -60
  66. package/src/haClient/updates.ts +0 -58
  67. package/src/haClient/wsCommand.ts +0 -44
  68. package/src/index.ts +0 -164
  69. package/src/localAI/index.ts +0 -88
  70. package/src/localAI/prompts.ts +0 -50
  71. package/src/localAI/providers/base.ts +0 -120
  72. package/src/localAI/providers/ollama.ts +0 -78
  73. package/src/localAI/providers/openai.ts +0 -86
  74. package/src/localAI/types.ts +0 -67
  75. package/src/omadaClient/auth.ts +0 -178
  76. package/src/omadaClient/client.ts +0 -337
  77. package/src/omadaClient/device.ts +0 -118
  78. package/src/omadaClient/index.ts +0 -310
  79. package/src/omadaClient/network.ts +0 -284
  80. package/src/omadaClient/request.ts +0 -365
  81. package/src/omadaClient/security.ts +0 -51
  82. package/src/omadaClient/site.ts +0 -344
  83. package/src/ownerQuestions/service.ts +0 -536
  84. package/src/permissions/index.ts +0 -143
  85. package/src/resources/index.ts +0 -213
  86. package/src/server/auth.ts +0 -175
  87. package/src/server/chatFace.ts +0 -789
  88. package/src/server/chatSlice.ts +0 -57
  89. package/src/server/common.ts +0 -170
  90. package/src/server/eventSubscription.ts +0 -343
  91. package/src/server/http.ts +0 -626
  92. package/src/server/instructions.ts +0 -97
  93. package/src/server/rateLimiter.ts +0 -227
  94. package/src/server/stdio.ts +0 -41
  95. package/src/server/stream.ts +0 -205
  96. package/src/tools/ai/analyzeSensors.ts +0 -33
  97. package/src/tools/ai/index.ts +0 -20
  98. package/src/tools/common.ts +0 -442
  99. package/src/tools/homeassistant/askOwner.ts +0 -133
  100. package/src/tools/homeassistant/automationControls.ts +0 -66
  101. package/src/tools/homeassistant/calendar.ts +0 -97
  102. package/src/tools/homeassistant/callService.ts +0 -25
  103. package/src/tools/homeassistant/controlClimate.ts +0 -68
  104. package/src/tools/homeassistant/controlCover.ts +0 -48
  105. package/src/tools/homeassistant/controlFan.ts +0 -44
  106. package/src/tools/homeassistant/controlLight.ts +0 -38
  107. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  108. package/src/tools/homeassistant/createAutomation.ts +0 -35
  109. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  110. package/src/tools/homeassistant/entityAction.ts +0 -35
  111. package/src/tools/homeassistant/entityGuard.ts +0 -28
  112. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  113. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  114. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  115. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  116. package/src/tools/homeassistant/getHistory.ts +0 -24
  117. package/src/tools/homeassistant/getState.ts +0 -24
  118. package/src/tools/homeassistant/getStates.ts +0 -46
  119. package/src/tools/homeassistant/getVersion.ts +0 -21
  120. package/src/tools/homeassistant/index.ts +0 -121
  121. package/src/tools/homeassistant/listAutomations.ts +0 -35
  122. package/src/tools/homeassistant/listEntities.ts +0 -44
  123. package/src/tools/homeassistant/listPersons.ts +0 -46
  124. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  125. package/src/tools/homeassistant/searchEntities.ts +0 -58
  126. package/src/tools/homeassistant/sendNotification.ts +0 -179
  127. package/src/tools/homeassistant/systemTools.ts +0 -52
  128. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  129. package/src/tools/index.ts +0 -78
  130. package/src/tools/infra/actions.ts +0 -95
  131. package/src/tools/infra/index.ts +0 -138
  132. package/src/tools/omada/blockClient.ts +0 -28
  133. package/src/tools/omada/cyclePoePort.ts +0 -32
  134. package/src/tools/omada/deleteClient.ts +0 -27
  135. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  136. package/src/tools/omada/getClient.ts +0 -14
  137. package/src/tools/omada/getDevice.ts +0 -14
  138. package/src/tools/omada/getFirewallSetting.ts +0 -14
  139. package/src/tools/omada/getInternetInfo.ts +0 -14
  140. package/src/tools/omada/getLanNetworkList.ts +0 -14
  141. package/src/tools/omada/getLanProfileList.ts +0 -14
  142. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  143. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  144. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  145. package/src/tools/omada/getSsidDetail.ts +0 -22
  146. package/src/tools/omada/getSsidList.ts +0 -21
  147. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  148. package/src/tools/omada/getThreatList.ts +0 -42
  149. package/src/tools/omada/getWlanGroupList.ts +0 -14
  150. package/src/tools/omada/graph.ts +0 -187
  151. package/src/tools/omada/index.ts +0 -153
  152. package/src/tools/omada/listBlockedClients.ts +0 -17
  153. package/src/tools/omada/listClients.ts +0 -14
  154. package/src/tools/omada/listClientsActivity.ts +0 -25
  155. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  156. package/src/tools/omada/listDevices.ts +0 -14
  157. package/src/tools/omada/listDevicesStats.ts +0 -41
  158. package/src/tools/omada/listMostActiveClients.ts +0 -14
  159. package/src/tools/omada/listSites.ts +0 -18
  160. package/src/tools/omada/namespace.ts +0 -1164
  161. package/src/tools/omada/searchDevices.ts +0 -20
  162. package/src/tools/omada/setClientRateLimit.ts +0 -26
  163. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  164. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  165. package/src/tools/omada/setSsidEnabled.ts +0 -38
  166. package/src/tools/omada/unblockClient.ts +0 -26
  167. package/src/tools/types.ts +0 -11
  168. package/src/tools/utils/pagination-schema.ts +0 -27
  169. package/src/types/ai/index.ts +0 -11
  170. package/src/types/ai/types.ts +0 -67
  171. package/src/types/homeassistant/automations.ts +0 -43
  172. package/src/types/homeassistant/calendars.ts +0 -26
  173. package/src/types/homeassistant/config.ts +0 -14
  174. package/src/types/homeassistant/entities.ts +0 -14
  175. package/src/types/homeassistant/errors.ts +0 -33
  176. package/src/types/homeassistant/haConfig.ts +0 -7
  177. package/src/types/homeassistant/history.ts +0 -13
  178. package/src/types/homeassistant/index.ts +0 -46
  179. package/src/types/homeassistant/request.ts +0 -6
  180. package/src/types/homeassistant/serviceCall.ts +0 -18
  181. package/src/types/homeassistant/states.ts +0 -58
  182. package/src/types/homeassistant/updates.ts +0 -28
  183. package/src/types/index.ts +0 -12
  184. package/src/types/omada/activeClientInfo.ts +0 -17
  185. package/src/types/omada/clientActivity.ts +0 -32
  186. package/src/types/omada/clientBlockStatus.ts +0 -11
  187. package/src/types/omada/clientPastConnection.ts +0 -82
  188. package/src/types/omada/index.ts +0 -14
  189. package/src/types/omada/omadaApiResponse.ts +0 -5
  190. package/src/types/omada/omadaClientInfo.ts +0 -11
  191. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  192. package/src/types/omada/omadaDeviceStats.ts +0 -31
  193. package/src/types/omada/omadaSiteSummary.ts +0 -5
  194. package/src/types/omada/oswStackDetail.ts +0 -11
  195. package/src/types/omada/paginatedResult.ts +0 -6
  196. package/src/types/omada/rateLimitProfile.ts +0 -58
  197. package/src/types/omada/threatInfo.ts +0 -38
  198. package/src/types/omada/tokenResult.ts +0 -6
  199. package/src/types.ts +0 -2
  200. package/src/utils/config-validations.ts +0 -68
  201. package/src/utils/jwt.ts +0 -112
  202. package/src/utils/logger.ts +0 -121
  203. package/src/utils/plainText.ts +0 -48
  204. package/src/utils/redact.ts +0 -59
  205. package/src/utils/sanitizeError.ts +0 -18
  206. package/src/utils/tlsDispatcher.ts +0 -69
  207. package/src/version.ts +0 -5
  208. package/tests/bdd/automation-creation.spec.ts +0 -214
  209. package/tests/bdd/automation-management.spec.ts +0 -267
  210. package/tests/bdd/calendar.spec.ts +0 -190
  211. package/tests/bdd/configuration.spec.ts +0 -283
  212. package/tests/bdd/device-control.spec.ts +0 -344
  213. package/tests/bdd/entity-listing.spec.ts +0 -208
  214. package/tests/bdd/entity-search.spec.ts +0 -183
  215. package/tests/bdd/history-retrieval.spec.ts +0 -195
  216. package/tests/bdd/notifications.spec.ts +0 -238
  217. package/tests/bdd/service-calls.spec.ts +0 -144
  218. package/tests/bdd/state-retrieval.spec.ts +0 -204
  219. package/tests/bench/hotpaths.bench.ts +0 -68
  220. package/tests/config.test.ts +0 -355
  221. package/tests/features/automation-creation.feature +0 -36
  222. package/tests/features/automation-management.feature +0 -54
  223. package/tests/features/calendar.feature +0 -37
  224. package/tests/features/configuration.feature +0 -70
  225. package/tests/features/device-control.feature +0 -100
  226. package/tests/features/entity-listing.feature +0 -51
  227. package/tests/features/entity-search.feature +0 -44
  228. package/tests/features/history-retrieval.feature +0 -38
  229. package/tests/features/notifications.feature +0 -37
  230. package/tests/features/service-calls.feature +0 -35
  231. package/tests/features/state-retrieval.feature +0 -37
  232. package/tests/fixtures/openapi-default-slice.json +0 -290
  233. package/tests/haClient/automationTrace.test.ts +0 -59
  234. package/tests/haClient/automations.test.ts +0 -149
  235. package/tests/haClient/config.test.ts +0 -117
  236. package/tests/haClient/entities.test.ts +0 -188
  237. package/tests/haClient/events.test.ts +0 -455
  238. package/tests/haClient/eventsTls.test.ts +0 -52
  239. package/tests/haClient/history.test.ts +0 -228
  240. package/tests/haClient/index.test.ts +0 -243
  241. package/tests/haClient/request.test.ts +0 -217
  242. package/tests/haClient/services.test.ts +0 -120
  243. package/tests/haClient/states.test.ts +0 -119
  244. package/tests/haClient/updates.test.ts +0 -193
  245. package/tests/index.test.ts +0 -160
  246. package/tests/integration/homeassistant.test.ts +0 -144
  247. package/tests/integration/omada.test.ts +0 -167
  248. package/tests/integration/server.test.ts +0 -151
  249. package/tests/localAI/index.test.ts +0 -177
  250. package/tests/localAI/ollama.test.ts +0 -187
  251. package/tests/localAI/openai.test.ts +0 -257
  252. package/tests/omadaClient/actions.test.ts +0 -161
  253. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  254. package/tests/omadaClient/ntp.test.ts +0 -381
  255. package/tests/omadaClient/site.test.ts +0 -37
  256. package/tests/ownerQuestions/service.test.ts +0 -499
  257. package/tests/ownerQuestions/tools.test.ts +0 -186
  258. package/tests/permissions/index.test.ts +0 -217
  259. package/tests/resources/index.test.ts +0 -288
  260. package/tests/scripts/generateJwt.test.ts +0 -44
  261. package/tests/security/accessControl.test.ts +0 -223
  262. package/tests/security/authentication.test.ts +0 -117
  263. package/tests/security/configuration.test.ts +0 -170
  264. package/tests/security/cryptoTransport.test.ts +0 -172
  265. package/tests/security/dependencies.test.ts +0 -152
  266. package/tests/security/injection.test.ts +0 -117
  267. package/tests/security/insecureDesign.test.ts +0 -121
  268. package/tests/security/logging.test.ts +0 -131
  269. package/tests/security/resourceConsumption.test.ts +0 -142
  270. package/tests/server/auth.test.ts +0 -249
  271. package/tests/server/chatFace.test.ts +0 -320
  272. package/tests/server/common.test.ts +0 -84
  273. package/tests/server/http.test.ts +0 -561
  274. package/tests/server/instructions.test.ts +0 -152
  275. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  276. package/tests/server/stdio.test.ts +0 -99
  277. package/tests/server/stream.test.ts +0 -285
  278. package/tests/server/toolVisibility.http.test.ts +0 -111
  279. package/tests/server/toolVisibility.test.ts +0 -129
  280. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  281. package/tests/tools/common.test.ts +0 -123
  282. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  283. package/tests/tools/infra/invokeAction.test.ts +0 -280
  284. package/tests/tools/omada/graph.test.ts +0 -385
  285. package/tests/tools/omada/handlers.test.ts +0 -897
  286. package/tests/tools/omada/namespace.test.ts +0 -483
  287. package/tests/tools/registry.test.ts +0 -168
  288. package/tests/utils/config-validations.test.ts +0 -352
  289. package/tests/utils/jwt.test.ts +0 -115
  290. package/tests/utils/logger.test.ts +0 -327
  291. package/tests/utils/redact.test.ts +0 -105
  292. package/tsconfig.json +0 -24
  293. 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
- });