@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,280 +0,0 @@
1
- /**
2
- * Unit tests for the infra plugin: MCP_REST_ACTIONS parsing and the invokeAction
3
- * tool ("actuator rails for HTTP").
4
- *
5
- * The security property under test throughout: the model's ONLY input is an
6
- * action NAME resolved by exact own-property lookup — a malformed or hostile
7
- * string can fail the lookup, and nothing else. It never reaches URL, method,
8
- * headers, or body.
9
- */
10
-
11
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
- import type { McpServer } from '@modelcontextprotocol/server';
13
- import { registerInfraTools, parseRestActions, resetActionCooldowns } from '../../../src/tools/infra/index.js';
14
- import { Permission } from '../../../src/tools/common.js';
15
-
16
- describe('parseRestActions', () => {
17
- it('returns an empty map for unset/blank input', () => {
18
- expect(parseRestActions(undefined)).toEqual({});
19
- expect(parseRestActions('')).toEqual({});
20
- expect(parseRestActions(' ')).toEqual({});
21
- });
22
-
23
- it('parses a valid action map (method defaults to POST)', () => {
24
- const parsed = parseRestActions(
25
- '{"update-node-u2":{"url":"http://192.168.0.9:8425/v1/update","bearerToken":"secret-1","description":"deploy latest images"}}'
26
- );
27
- expect(parsed['update-node-u2']).toMatchObject({
28
- method: 'POST',
29
- url: 'http://192.168.0.9:8425/v1/update',
30
- bearerToken: 'secret-1',
31
- });
32
- });
33
-
34
- it('returns a null-prototype map (no Object.prototype reachable through it)', () => {
35
- const parsed = parseRestActions('{"ok-action":{"url":"http://h:1/x"}}');
36
- expect(Object.getPrototypeOf(parsed)).toBeNull();
37
- });
38
-
39
- it('fails loudly on malformed JSON, arrays, and non-objects', () => {
40
- expect(() => parseRestActions('not-json')).toThrow(/must be valid JSON/);
41
- expect(() => parseRestActions('[1,2]')).toThrow(/JSON object keyed by action name/);
42
- expect(() => parseRestActions('"str"')).toThrow(/JSON object keyed by action name/);
43
- });
44
-
45
- it('rejects hostile or malformed action names at config time', () => {
46
- for (const bad of ['__proto__', 'has space', 'UPPER', 'a/../b', 'x'.repeat(65), '-startdash']) {
47
- expect(() => parseRestActions(`{"${bad}":{"url":"http://h:1/x"}}`)).toThrow(/Invalid MCP_REST_ACTIONS action name/);
48
- }
49
- });
50
-
51
- it('rejects invalid urls, non-http(s) schemes, and bad methods', () => {
52
- expect(() => parseRestActions('{"a":{"url":"nope"}}')).toThrow(/Invalid MCP_REST_ACTIONS entry/);
53
- expect(() => parseRestActions('{"a":{"url":"file:///etc/passwd"}}')).toThrow(/only http\(s\)/);
54
- expect(() => parseRestActions('{"a":{"url":"http://h:1/x","method":"DELETE"}}')).toThrow(/Invalid MCP_REST_ACTIONS entry/);
55
- });
56
- });
57
-
58
- // Mock server that captures registered handlers (same pattern as omada handler tests)
59
- function createMockServer() {
60
- const handlers = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
61
- return {
62
- registerTool: vi.fn((name: string, config: unknown, handler: (...args: unknown[]) => unknown) => {
63
- handlers.set(name, { config, handler });
64
- }),
65
- handlers,
66
- } as unknown as McpServer & { handlers: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
67
- }
68
- const adminExtra = {
69
- sessionId: 'test-session',
70
- http: { authInfo: { extra: { permissions: 0xff } } },
71
- };
72
-
73
- const ACTIONS = parseRestActions(
74
- JSON.stringify({
75
- 'update-node-u2': {
76
- url: 'http://192.168.0.9:8425/v1/update',
77
- bearerToken: 'tok-u2',
78
- description: 'deploy latest images on node-u2',
79
- },
80
- 'ping-webhook': { url: 'http://192.168.0.18:5678/webhook/ping', method: 'GET' },
81
- })
82
- );
83
-
84
- describe('invokeAction tool', () => {
85
- let fetchSpy: ReturnType<typeof vi.spyOn>;
86
-
87
- beforeEach(() => {
88
- fetchSpy = vi.spyOn(globalThis, 'fetch');
89
- // The cooldown store is process-wide (module-level by design — sessions
90
- // must share it); clear it so cases can't poison each other.
91
- resetActionCooldowns();
92
- });
93
-
94
- afterEach(() => {
95
- fetchSpy.mockRestore();
96
- });
97
-
98
- it('does not register at all when no actions are configured', () => {
99
- const server = createMockServer();
100
- expect(registerInfraTools(server, {})).toBe(0);
101
- expect(server.handlers.size).toBe(0);
102
- });
103
-
104
- it('embeds the action names + descriptions in the tool description', () => {
105
- const server = createMockServer();
106
- expect(registerInfraTools(server, ACTIONS)).toBe(1);
107
- const description = (server.handlers.get('invokeAction')?.config as { description: string }).description;
108
- expect(description).toContain('update-node-u2 (deploy latest images on node-u2)');
109
- expect(description).toContain('ping-webhook');
110
- });
111
-
112
- it('fires the configured request exactly — model input shapes nothing but the lookup', async () => {
113
- fetchSpy.mockResolvedValue(new Response('OK', { status: 200 }));
114
- const server = createMockServer();
115
- registerInfraTools(server, ACTIONS);
116
-
117
- const { handler } = server.handlers.get('invokeAction')!;
118
- const result = (await handler({ action: 'update-node-u2' }, adminExtra)) as { isError?: boolean; content: Array<{ text: string }> };
119
-
120
- expect(fetchSpy).toHaveBeenCalledTimes(1);
121
- expect(fetchSpy).toHaveBeenCalledWith(
122
- 'http://192.168.0.9:8425/v1/update',
123
- expect.objectContaining({
124
- method: 'POST',
125
- headers: { Authorization: 'Bearer tok-u2' },
126
- redirect: 'error',
127
- })
128
- );
129
- expect(result.isError).toBeFalsy();
130
- expect(result.content[0].text).toContain('completed');
131
- });
132
-
133
- it('sends no Authorization header when the action has no token', async () => {
134
- fetchSpy.mockResolvedValue(new Response('pong', { status: 200 }));
135
- const server = createMockServer();
136
- registerInfraTools(server, ACTIONS);
137
-
138
- const { handler } = server.handlers.get('invokeAction')!;
139
- await handler({ action: 'ping-webhook' }, adminExtra);
140
-
141
- expect(fetchSpy).toHaveBeenCalledWith(
142
- 'http://192.168.0.18:5678/webhook/ping',
143
- expect.objectContaining({ method: 'GET', headers: undefined })
144
- );
145
- });
146
-
147
- it('rejects unknown actions without touching the network', async () => {
148
- const server = createMockServer();
149
- registerInfraTools(server, ACTIONS);
150
-
151
- const { handler } = server.handlers.get('invokeAction')!;
152
- const result = (await handler({ action: 'rm-rf-everything' }, adminExtra)) as { isError?: boolean; content: Array<{ text: string }> };
153
-
154
- expect(result.isError).toBe(true);
155
- expect(result.content[0].text).toMatch(/Unknown action 'rm-rf-everything'/);
156
- expect(fetchSpy).not.toHaveBeenCalled();
157
- });
158
-
159
- it('cannot be escaped via prototype-chain names — lookup is own-property only', async () => {
160
- const server = createMockServer();
161
- registerInfraTools(server, ACTIONS);
162
- const { handler } = server.handlers.get('invokeAction')!;
163
-
164
- for (const hostile of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) {
165
- const result = (await handler({ action: hostile }, adminExtra)) as { isError?: boolean; content: Array<{ text: string }> };
166
- expect(result.isError).toBe(true);
167
- expect(result.content[0].text).toContain('Unknown action');
168
- }
169
- expect(fetchSpy).not.toHaveBeenCalled();
170
- });
171
-
172
- it('surfaces non-200 responses as errors (body truncated)', async () => {
173
- fetchSpy.mockResolvedValue(new Response('x'.repeat(2000), { status: 503 }));
174
- const server = createMockServer();
175
- registerInfraTools(server, ACTIONS);
176
-
177
- const { handler } = server.handlers.get('invokeAction')!;
178
- const result = (await handler({ action: 'update-node-u2' }, adminExtra)) as { isError?: boolean; content: Array<{ text: string }> };
179
-
180
- expect(result.isError).toBe(true);
181
- expect(result.content[0].text).toMatch(/HTTP 503/);
182
- expect(result.content[0].text.length).toBeLessThan(700); // 500-char cap + wrapper
183
- });
184
-
185
- it('rate-limits: a second firing within the cooldown is rejected without touching the network', async () => {
186
- fetchSpy.mockResolvedValue(new Response('OK', { status: 200 }));
187
- const server = createMockServer();
188
- registerInfraTools(server, ACTIONS);
189
- const { handler } = server.handlers.get('invokeAction')!;
190
-
191
- const first = (await handler({ action: 'update-node-u2' }, adminExtra)) as { isError?: boolean };
192
- expect(first.isError).toBeFalsy();
193
-
194
- const second = (await handler({ action: 'update-node-u2' }, adminExtra)) as { isError?: boolean; content: Array<{ text: string }> };
195
- expect(second.isError).toBe(true);
196
- expect(second.content[0].text).toMatch(/cooling down/);
197
- expect(fetchSpy).toHaveBeenCalledTimes(1);
198
- });
199
-
200
- it('rate-limits per action: cooling one action does not block another', async () => {
201
- fetchSpy.mockResolvedValue(new Response('OK', { status: 200 }));
202
- const server = createMockServer();
203
- registerInfraTools(server, ACTIONS);
204
- const { handler } = server.handlers.get('invokeAction')!;
205
-
206
- await handler({ action: 'update-node-u2' }, adminExtra);
207
- const other = (await handler({ action: 'ping-webhook' }, adminExtra)) as { isError?: boolean };
208
- expect(other.isError).toBeFalsy();
209
- expect(fetchSpy).toHaveBeenCalledTimes(2);
210
- });
211
-
212
- it('allows firing again once the cooldown has elapsed', async () => {
213
- vi.useFakeTimers();
214
- try {
215
- fetchSpy.mockResolvedValue(new Response('OK', { status: 200 }));
216
- const server = createMockServer();
217
- registerInfraTools(server, ACTIONS);
218
- const { handler } = server.handlers.get('invokeAction')!;
219
-
220
- await handler({ action: 'update-node-u2' }, adminExtra);
221
- vi.advanceTimersByTime(60_001); // default cooldown is 60s
222
- const again = (await handler({ action: 'update-node-u2' }, adminExtra)) as { isError?: boolean };
223
- expect(again.isError).toBeFalsy();
224
- expect(fetchSpy).toHaveBeenCalledTimes(2);
225
- } finally {
226
- vi.useRealTimers();
227
- }
228
- });
229
-
230
- it('a FAILED firing still consumes the cooldown slot (no hammering a dying endpoint)', async () => {
231
- fetchSpy.mockResolvedValue(new Response('boom', { status: 503 }));
232
- const server = createMockServer();
233
- registerInfraTools(server, ACTIONS);
234
- const { handler } = server.handlers.get('invokeAction')!;
235
-
236
- const first = (await handler({ action: 'update-node-u2' }, adminExtra)) as { isError?: boolean };
237
- expect(first.isError).toBe(true); // HTTP 503 surfaced
238
- const retry = (await handler({ action: 'update-node-u2' }, adminExtra)) as { isError?: boolean; content: Array<{ text: string }> };
239
- expect(retry.isError).toBe(true);
240
- expect(retry.content[0].text).toMatch(/cooling down/);
241
- expect(fetchSpy).toHaveBeenCalledTimes(1);
242
- });
243
-
244
- it('cooldownMs: 0 disables the rate limit for that action', async () => {
245
- fetchSpy.mockResolvedValue(new Response('OK', { status: 200 }));
246
- const noCooldown = parseRestActions(
247
- '{"rapid":{"url":"http://192.168.0.9:1/x","cooldownMs":0}}'
248
- );
249
- const server = createMockServer();
250
- registerInfraTools(server, noCooldown);
251
- const { handler } = server.handlers.get('invokeAction')!;
252
-
253
- await handler({ action: 'rapid' }, adminExtra);
254
- const second = (await handler({ action: 'rapid' }, adminExtra)) as { isError?: boolean };
255
- expect(second.isError).toBeFalsy();
256
- expect(fetchSpy).toHaveBeenCalledTimes(2);
257
- });
258
-
259
- it('rejects invalid cooldownMs at config parse', () => {
260
- expect(() => parseRestActions('{"a":{"url":"http://h:1/x","cooldownMs":-1}}')).toThrow(/Invalid MCP_REST_ACTIONS entry/);
261
- expect(() => parseRestActions('{"a":{"url":"http://h:1/x","cooldownMs":999999999}}')).toThrow(/Invalid MCP_REST_ACTIONS entry/);
262
- expect(parseRestActions('{"a":{"url":"http://h:1/x","cooldownMs":30000}}')['a'].cooldownMs).toBe(30000);
263
- });
264
-
265
- it('denies callers without the ADMIN permission bit', async () => {
266
- const server = createMockServer();
267
- registerInfraTools(server, ACTIONS);
268
- const operatorExtra = {
269
- sessionId: 'test-session',
270
- // OPERATOR mask = QUERY|CONTROL|NOTIFY — no ADMIN bit
271
- http: { authInfo: { extra: { permissions: Permission.QUERY | Permission.CONTROL | Permission.NOTIFY } } },
272
- };
273
-
274
- const { handler } = server.handlers.get('invokeAction')!;
275
- const result = (await handler({ action: 'update-node-u2' }, operatorExtra)) as { isError?: boolean };
276
-
277
- expect(result.isError).toBe(true);
278
- expect(fetchSpy).not.toHaveBeenCalled();
279
- });
280
- });
@@ -1,385 +0,0 @@
1
- /**
2
- * Unit tests for the Omada resource-graph tools (omada_browse + omada_read).
3
- */
4
-
5
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
- import type { McpServer } from '@modelcontextprotocol/server';
7
- import type { OmadaClient } from '../../../src/omadaClient/index.js';
8
- import { registerOmadaGraphTools } from '../../../src/tools/omada/graph.js';
9
- import { registerOmadaTools } from '../../../src/tools/omada/index.js';
10
- import { Permission } from '../../../src/permissions/index.js';
11
-
12
- function createMockServer() {
13
- const handlers = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
14
- return {
15
- registerTool: vi.fn((name: string, config: unknown, handler: (...args: unknown[]) => unknown) => {
16
- handlers.set(name, { config, handler });
17
- }),
18
- handlers,
19
- } as unknown as McpServer & {
20
- registerTool: ReturnType<typeof vi.fn>;
21
- handlers: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>;
22
- };
23
- }
24
-
25
- function createMockClient() {
26
- return {
27
- listSites: vi.fn(),
28
- listDevices: vi.fn(),
29
- getDevice: vi.fn(),
30
- listClients: vi.fn(),
31
- getClient: vi.fn(),
32
- listMostActiveClients: vi.fn(),
33
- getInternetInfo: vi.fn(),
34
- getSiteNtpStatus: vi.fn(),
35
- getLanNetworkList: vi.fn(),
36
- getLanProfileList: vi.fn(),
37
- getWlanGroupList: vi.fn(),
38
- getFirewallSetting: vi.fn(),
39
- listDevicesStats: vi.fn(),
40
- getSwitchStackDetail: vi.fn(),
41
- listClientsActivity: vi.fn(),
42
- listClientsPastConnections: vi.fn(),
43
- getPortForwardingStatus: vi.fn(),
44
- getRateLimitProfiles: vi.fn(),
45
- getSsidList: vi.fn(),
46
- getSsidDetail: vi.fn(),
47
- getThreatList: vi.fn(),
48
- readResource: vi.fn(),
49
- // writes (graph mode also registers these)
50
- setClientRateLimit: vi.fn(),
51
- setClientRateLimitProfile: vi.fn(),
52
- disableClientRateLimit: vi.fn(),
53
- blockClient: vi.fn(),
54
- unblockClient: vi.fn(),
55
- } as unknown as OmadaClient;
56
- }
57
-
58
- // Caller with all permissions.
59
- const adminExtra = { sessionId: 'test', http: { authInfo: { extra: { permissions: 0xff } } } };
60
- // Caller with only QUERY (read-only).
61
- const readonlyExtra = { sessionId: 'test', http: { authInfo: { extra: { permissions: Permission.QUERY } } } };
62
- // Caller with NO permissions.
63
- const noPermsExtra = { sessionId: 'test', http: { authInfo: { extra: { permissions: 0 } } } };
64
-
65
- function parseResult(result: { content: { text: string }[]; isError?: boolean }): {
66
- isError?: boolean;
67
- data: Record<string, unknown>;
68
- } {
69
- return { isError: result.isError, data: JSON.parse(result.content[0].text) };
70
- }
71
-
72
- describe('Omada graph tools - registration', () => {
73
- it('registerOmadaGraphTools registers omada_browse and omada_read', () => {
74
- const server = createMockServer();
75
- const client = createMockClient();
76
- const count = registerOmadaGraphTools(server, client);
77
- expect(count).toBe(2);
78
- expect(server.handlers.has('omada_browse')).toBe(true);
79
- expect(server.handlers.has('omada_read')).toBe(true);
80
- });
81
-
82
- it('graph mode registers browse + read + 9 typed writes, and NO typed getters', () => {
83
- const server = createMockServer();
84
- const client = createMockClient();
85
- const count = registerOmadaTools(server, client, 'graph');
86
- expect(count).toBe(11);
87
- const names = [...server.handlers.keys()];
88
- expect(names).toContain('omada_browse');
89
- expect(names).toContain('omada_read');
90
- expect(names).toContain('omada_blockClient');
91
- expect(names).toContain('omada_setClientRateLimit');
92
- // v1.6 action tools ride along in graph mode too (writes stay typed).
93
- expect(names).toContain('omada_cyclePoePort');
94
- expect(names).toContain('omada_setSsidEnabled');
95
- expect(names).toContain('omada_setSiteNtpServers');
96
- expect(names).toContain('omada_deleteClient');
97
- // Typed read getters must NOT be present in graph mode.
98
- expect(names).not.toContain('omada_listSites');
99
- expect(names).not.toContain('omada_getFirewallSetting');
100
- expect(names).not.toContain('omada_getSiteNtpStatus');
101
- expect(names).not.toContain('omada_listBlockedClients');
102
- });
103
-
104
- it('eager mode (default) registers the typed getters, not the graph tools', () => {
105
- const server = createMockServer();
106
- const client = createMockClient();
107
- registerOmadaTools(server, client);
108
- const names = [...server.handlers.keys()];
109
- expect(names).toContain('omada_listSites');
110
- expect(names).not.toContain('omada_browse');
111
- expect(names).not.toContain('omada_read');
112
- });
113
- });
114
-
115
- describe('omada_browse', () => {
116
- let server: ReturnType<typeof createMockServer>;
117
- let client: ReturnType<typeof createMockClient>;
118
-
119
- beforeEach(() => {
120
- server = createMockServer();
121
- client = createMockClient();
122
- vi.clearAllMocks();
123
- registerOmadaGraphTools(server, client);
124
- });
125
-
126
- function browse(args: { path?: string }, extra: unknown) {
127
- return server.handlers.get('omada_browse')!.handler(args, extra) as Promise<{
128
- content: { text: string }[];
129
- isError?: boolean;
130
- }>;
131
- }
132
-
133
- it('lists top-level resource types at root', async () => {
134
- const { isError, data } = parseResult(await browse({ path: '/' }, adminExtra));
135
- expect(isError).toBeFalsy();
136
- expect(data.path).toBe('/');
137
- const childPaths = (data.children as Array<{ path: string }>).map((c) => c.path);
138
- expect(childPaths).toEqual(expect.arrayContaining(['/sites', '/clients', '/gateway', '/wifi']));
139
- });
140
-
141
- it('defaults to root when no path is given', async () => {
142
- const { data } = parseResult(await browse({}, adminExtra));
143
- expect(data.path).toBe('/');
144
- });
145
-
146
- it('surfaces metadata (kind, permission, readable, pagination) for children', async () => {
147
- const { data } = parseResult(await browse({ path: '/events' }, adminExtra));
148
- expect(data.supportsPagination).toBe(true);
149
- expect(data.defaultPageSize).toBe(50);
150
- });
151
-
152
- it('returns an error for an unknown path', async () => {
153
- const { isError, data } = parseResult(await browse({ path: '/nope' }, adminExtra));
154
- expect(isError).toBe(true);
155
- expect(data.error).toBe('Unknown path');
156
- });
157
-
158
- it('is denied for callers lacking QUERY', async () => {
159
- const { isError, data } = parseResult(await browse({ path: '/' }, noPermsExtra));
160
- expect(isError).toBe(true);
161
- expect(data.error).toBe('Permission denied');
162
- });
163
-
164
- it('read-only callers can browse (all Tier-1 nodes are QUERY)', async () => {
165
- const { isError, data } = parseResult(await browse({ path: '/gateway' }, readonlyExtra));
166
- expect(isError).toBeFalsy();
167
- const childPaths = (data.children as Array<{ path: string }>).map((c) => c.path);
168
- expect(childPaths).toEqual(expect.arrayContaining(['/gateway/wan', '/gateway/health']));
169
- });
170
-
171
- it('advertises /security at root to ADMIN callers', async () => {
172
- const { data } = parseResult(await browse({ path: '/' }, adminExtra));
173
- const childPaths = (data.children as Array<{ path: string }>).map((c) => c.path);
174
- expect(childPaths).toContain('/security');
175
- });
176
-
177
- it('hides the ADMIN-only /security subtree from QUERY-only callers', async () => {
178
- const { data } = parseResult(await browse({ path: '/' }, readonlyExtra));
179
- const childPaths = (data.children as Array<{ path: string }>).map((c) => c.path);
180
- expect(childPaths).not.toContain('/security');
181
- });
182
- });
183
-
184
- describe('omada_read', () => {
185
- let server: ReturnType<typeof createMockServer>;
186
- let client: ReturnType<typeof createMockClient>;
187
-
188
- beforeEach(() => {
189
- server = createMockServer();
190
- client = createMockClient();
191
- vi.clearAllMocks();
192
- registerOmadaGraphTools(server, client);
193
- });
194
-
195
- function read(args: Record<string, unknown>, extra: unknown) {
196
- return server.handlers.get('omada_read')!.handler(args, extra) as Promise<{
197
- content: { text: string }[];
198
- isError?: boolean;
199
- }>;
200
- }
201
-
202
- it('reads a collection via the backing client method', async () => {
203
- (client.listSites as ReturnType<typeof vi.fn>).mockResolvedValue([{ id: 's1' }]);
204
- const { isError, data } = parseResult(await read({ path: '/sites' }, adminExtra));
205
- expect(isError).toBeFalsy();
206
- expect(data).toEqual([{ id: 's1' }]);
207
- expect(client.listSites).toHaveBeenCalled();
208
- });
209
-
210
- it('looks up a single collection member when id is provided', async () => {
211
- (client.getClient as ReturnType<typeof vi.fn>).mockResolvedValue({ mac: 'AA' });
212
- await read({ path: '/clients', id: 'AA', siteId: 'site1' }, adminExtra);
213
- expect(client.getClient).toHaveBeenCalledWith('AA', 'site1');
214
- expect(client.listClients).not.toHaveBeenCalled();
215
- });
216
-
217
- it('lists the collection when no id is provided', async () => {
218
- (client.listClients as ReturnType<typeof vi.fn>).mockResolvedValue([]);
219
- await read({ path: '/clients' }, adminExtra);
220
- expect(client.listClients).toHaveBeenCalled();
221
- expect(client.getClient).not.toHaveBeenCalled();
222
- });
223
-
224
- it('routes generic leaves through readResource with the right template', async () => {
225
- (client.readResource as ReturnType<typeof vi.fn>).mockResolvedValue({ wanIp: '1.2.3.4' });
226
- const { data } = parseResult(
227
- await read({ path: '/gateway/wan', params: { gatewayMac: 'AA-BB' }, siteId: 'site1' }, adminExtra)
228
- );
229
- expect(data).toEqual({ wanIp: '1.2.3.4' });
230
- expect(client.readResource).toHaveBeenCalledWith(
231
- expect.objectContaining({
232
- pathTemplate: '/sites/{siteId}/gateways/{gatewayMac}/wan-status',
233
- siteId: 'site1',
234
- pathParams: { gatewayMac: 'AA-BB' },
235
- })
236
- );
237
- });
238
-
239
- it('forwards pagination to paginated resources', async () => {
240
- (client.readResource as ReturnType<typeof vi.fn>).mockResolvedValue({ data: [] });
241
- await read({ path: '/events', page: 2, pageSize: 25 }, adminExtra);
242
- expect(client.readResource).toHaveBeenCalledWith(
243
- expect.objectContaining({ paginated: true, page: 2, pageSize: 25 })
244
- );
245
- });
246
-
247
- it('reads the controller-global firmware overview without a site', async () => {
248
- (client.readResource as ReturnType<typeof vi.fn>).mockResolvedValue({ critical: 0 });
249
- await read({ path: '/firmware/critical' }, adminExtra);
250
- expect(client.readResource).toHaveBeenCalledWith(
251
- expect.objectContaining({ pathTemplate: '/upgrade/overview/critical', siteScoped: false })
252
- );
253
- });
254
-
255
- it('rejects missing required path parameters before calling the client', async () => {
256
- const { isError, data } = parseResult(await read({ path: '/gateway/wan' }, adminExtra));
257
- expect(isError).toBe(true);
258
- expect(data.error).toBe('Missing parameters');
259
- expect(data.missing).toEqual(['gatewayMac']);
260
- expect(client.readResource).not.toHaveBeenCalled();
261
- });
262
-
263
- it('returns an error for an unknown path', async () => {
264
- const { isError, data } = parseResult(await read({ path: '/nope' }, adminExtra));
265
- expect(isError).toBe(true);
266
- expect(data.error).toBe('Unknown path');
267
- });
268
-
269
- it('refuses to read a container node', async () => {
270
- const { isError, data } = parseResult(await read({ path: '/gateway' }, adminExtra));
271
- expect(isError).toBe(true);
272
- expect(data.error).toBe('Not readable');
273
- });
274
-
275
- it('defaults port-forwarding type to "User" and forwards pagination', async () => {
276
- (client.getPortForwardingStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ data: [] });
277
- await read({ path: '/network/port-forwarding', siteId: 's1', page: 3, pageSize: 5 }, adminExtra);
278
- expect(client.getPortForwardingStatus).toHaveBeenCalledWith('User', 's1', 3, 5);
279
- });
280
-
281
- it('routes /wifi/ssids to the list (wlanId) or detail (wlanId+ssidId)', async () => {
282
- (client.getSsidList as ReturnType<typeof vi.fn>).mockResolvedValue([]);
283
- (client.getSsidDetail as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 'x' });
284
-
285
- await read({ path: '/wifi/ssids', params: { wlanId: 'w1' }, siteId: 's1' }, adminExtra);
286
- expect(client.getSsidList).toHaveBeenCalledWith('w1', 's1');
287
-
288
- await read({ path: '/wifi/ssids', params: { wlanId: 'w1', ssidId: 's9' }, siteId: 's1' }, adminExtra);
289
- expect(client.getSsidDetail).toHaveBeenCalledWith('w1', 's9', 's1');
290
- });
291
-
292
- it('requires wlanId for /wifi/ssids', async () => {
293
- const { isError, data } = parseResult(await read({ path: '/wifi/ssids' }, adminExtra));
294
- expect(isError).toBe(true);
295
- expect(data.error).toBe('Missing parameters');
296
- expect(data.missing).toEqual(['wlanId']);
297
- });
298
-
299
- it('reads /network/ntp via getSiteNtpStatus, forwarding an explicit siteId', async () => {
300
- (client.getSiteNtpStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ ntpEnable: true, ntpServers: ['82.76.255.14'] });
301
- const { isError, data } = parseResult(await read({ path: '/network/ntp', siteId: 'site9' }, readonlyExtra));
302
- expect(isError).toBeFalsy();
303
- expect(data).toEqual({ ntpEnable: true, ntpServers: ['82.76.255.14'] });
304
- expect(client.getSiteNtpStatus).toHaveBeenCalledWith('site9');
305
- expect(client.getInternetInfo).not.toHaveBeenCalled();
306
- });
307
-
308
- it('denies /network/ntp without QUERY and never calls the controller', async () => {
309
- const { isError, data } = parseResult(await read({ path: '/network/ntp' }, noPermsExtra));
310
- expect(isError).toBe(true);
311
- expect(data.error).toBe('Permission denied');
312
- expect(client.getSiteNtpStatus).not.toHaveBeenCalled();
313
- });
314
-
315
- it('reads /clients/known as one page of known clients (with block state)', async () => {
316
- (client.readResource as ReturnType<typeof vi.fn>).mockResolvedValue({ totalRows: 1, data: [{ mac: 'AA', block: true }] });
317
- await read({ path: '/clients/known', page: 3, pageSize: 20, siteId: 'site1' }, readonlyExtra);
318
- expect(client.readResource).toHaveBeenCalledTimes(1);
319
- expect(client.readResource).toHaveBeenCalledWith(
320
- expect.objectContaining({ pathTemplate: '/sites/{siteId}/insight/clients', siteId: 'site1', paginated: true, page: 3, pageSize: 20 })
321
- );
322
- });
323
-
324
- it('enforces per-path permission (fail closed) for under-privileged callers', async () => {
325
- const { isError, data } = parseResult(await read({ path: '/sites' }, noPermsExtra));
326
- expect(isError).toBe(true);
327
- expect(data.error).toBe('Permission denied');
328
- expect(client.listSites).not.toHaveBeenCalled();
329
- });
330
-
331
- it('reads /security/threats via getThreatList, parsing the time window and filters', async () => {
332
- (client.getThreatList as ReturnType<typeof vi.fn>).mockResolvedValue({ data: [] });
333
- await read(
334
- {
335
- path: '/security/threats',
336
- params: { startTime: '1682000000', endTime: '1682003600', archived: 'true', severity: '1', sortTime: 'desc', searchKey: 'syn' },
337
- page: 2,
338
- pageSize: 25,
339
- },
340
- adminExtra
341
- );
342
- expect(client.getThreatList).toHaveBeenCalledWith({
343
- siteList: undefined,
344
- archived: true,
345
- page: 2,
346
- pageSize: 25,
347
- startTime: 1682000000,
348
- endTime: 1682003600,
349
- severity: 1,
350
- sortTime: 'desc',
351
- searchKey: 'syn',
352
- });
353
- });
354
-
355
- it('defaults /security/threats archived=false and page/pageSize when omitted', async () => {
356
- (client.getThreatList as ReturnType<typeof vi.fn>).mockResolvedValue({ data: [] });
357
- await read({ path: '/security/threats', params: { startTime: '1', endTime: '2' } }, adminExtra);
358
- expect(client.getThreatList).toHaveBeenCalledWith(
359
- expect.objectContaining({ archived: false, page: 1, pageSize: 10, severity: undefined, sortTime: undefined })
360
- );
361
- });
362
-
363
- it('requires the startTime/endTime window for /security/threats', async () => {
364
- const { isError, data } = parseResult(await read({ path: '/security/threats' }, adminExtra));
365
- expect(isError).toBe(true);
366
- expect(data.error).toBe('Missing parameters');
367
- expect(data.missing).toEqual(['startTime', 'endTime']);
368
- expect(client.getThreatList).not.toHaveBeenCalled();
369
- });
370
-
371
- it('denies /security/threats to QUERY-only callers (ADMIN required)', async () => {
372
- const { isError, data } = parseResult(
373
- await read({ path: '/security/threats', params: { startTime: '1', endTime: '2' } }, readonlyExtra)
374
- );
375
- expect(isError).toBe(true);
376
- expect(data.error).toBe('Permission denied');
377
- expect(client.getThreatList).not.toHaveBeenCalled();
378
- });
379
-
380
- it('refuses to read the /security container', async () => {
381
- const { isError, data } = parseResult(await read({ path: '/security' }, adminExtra));
382
- expect(isError).toBe(true);
383
- expect(data.error).toBe('Not readable');
384
- });
385
- });