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