@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.
- package/.env.example +1 -1
- package/README.md +11 -2
- package/dist/omadaClient/client.d.ts +6 -1
- package/dist/omadaClient/client.js +12 -1
- package/dist/omadaClient/client.js.map +1 -1
- package/dist/omadaClient/diagnose.d.ts +68 -0
- package/dist/omadaClient/diagnose.js +164 -0
- package/dist/omadaClient/diagnose.js.map +1 -0
- package/dist/omadaClient/index.d.ts +6 -0
- package/dist/omadaClient/index.js +15 -0
- package/dist/omadaClient/index.js.map +1 -1
- package/dist/omadaClient/macGroups.d.ts +58 -0
- package/dist/omadaClient/macGroups.js +124 -0
- package/dist/omadaClient/macGroups.js.map +1 -0
- package/dist/tools/infra/actions.d.ts +1 -1
- package/dist/tools/infra/actions.js +1 -1
- package/dist/tools/omada/index.js +6 -0
- package/dist/tools/omada/index.js.map +1 -1
- package/dist/tools/omada/macGroups.d.ts +19 -0
- package/dist/tools/omada/macGroups.js +77 -0
- package/dist/tools/omada/macGroups.js.map +1 -0
- package/dist/tools/omada/namespace.js +8 -0
- package/dist/tools/omada/namespace.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
- package/.codex/review-rules.md +0 -16
- package/.dockerignore +0 -8
- package/.gitattributes +0 -30
- package/.github/workflows/ci.yml +0 -193
- package/AGENTS.md +0 -389
- package/Dockerfile +0 -50
- package/docker-compose.yml +0 -23
- package/docs/CHANGELOG.md +0 -341
- package/docs/COVERAGE.md +0 -219
- package/docs/QUICK_START.md +0 -240
- package/eslint.config.js +0 -33
- package/n8n/ai-agent-safety-monitor.json +0 -355
- package/openapi.json +0 -1259
- package/scripts/check-version-sync.mjs +0 -69
- package/scripts/generate-jwt.ts +0 -121
- package/src/config.ts +0 -440
- package/src/haClient/automations.ts +0 -261
- package/src/haClient/calendars.ts +0 -94
- package/src/haClient/config.ts +0 -52
- package/src/haClient/devices.ts +0 -653
- package/src/haClient/entities.ts +0 -128
- package/src/haClient/events.ts +0 -689
- package/src/haClient/history.ts +0 -73
- package/src/haClient/index.ts +0 -336
- package/src/haClient/request.ts +0 -142
- package/src/haClient/services.ts +0 -45
- package/src/haClient/states.ts +0 -60
- package/src/haClient/updates.ts +0 -58
- package/src/haClient/wsCommand.ts +0 -44
- package/src/index.ts +0 -164
- package/src/localAI/index.ts +0 -88
- package/src/localAI/prompts.ts +0 -50
- package/src/localAI/providers/base.ts +0 -120
- package/src/localAI/providers/ollama.ts +0 -78
- package/src/localAI/providers/openai.ts +0 -86
- package/src/localAI/types.ts +0 -67
- package/src/omadaClient/auth.ts +0 -178
- package/src/omadaClient/client.ts +0 -337
- package/src/omadaClient/device.ts +0 -118
- package/src/omadaClient/index.ts +0 -310
- package/src/omadaClient/network.ts +0 -284
- package/src/omadaClient/request.ts +0 -365
- package/src/omadaClient/security.ts +0 -51
- package/src/omadaClient/site.ts +0 -344
- package/src/ownerQuestions/service.ts +0 -536
- package/src/permissions/index.ts +0 -143
- package/src/resources/index.ts +0 -213
- package/src/server/auth.ts +0 -175
- package/src/server/chatFace.ts +0 -789
- package/src/server/chatSlice.ts +0 -57
- package/src/server/common.ts +0 -170
- package/src/server/eventSubscription.ts +0 -343
- package/src/server/http.ts +0 -626
- package/src/server/instructions.ts +0 -97
- package/src/server/rateLimiter.ts +0 -227
- package/src/server/stdio.ts +0 -41
- package/src/server/stream.ts +0 -205
- package/src/tools/ai/analyzeSensors.ts +0 -33
- package/src/tools/ai/index.ts +0 -20
- package/src/tools/common.ts +0 -442
- package/src/tools/homeassistant/askOwner.ts +0 -133
- package/src/tools/homeassistant/automationControls.ts +0 -66
- package/src/tools/homeassistant/calendar.ts +0 -97
- package/src/tools/homeassistant/callService.ts +0 -25
- package/src/tools/homeassistant/controlClimate.ts +0 -68
- package/src/tools/homeassistant/controlCover.ts +0 -48
- package/src/tools/homeassistant/controlFan.ts +0 -44
- package/src/tools/homeassistant/controlLight.ts +0 -38
- package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
- package/src/tools/homeassistant/createAutomation.ts +0 -35
- package/src/tools/homeassistant/deleteAutomation.ts +0 -24
- package/src/tools/homeassistant/entityAction.ts +0 -35
- package/src/tools/homeassistant/entityGuard.ts +0 -28
- package/src/tools/homeassistant/getAllSensors.ts +0 -46
- package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
- package/src/tools/homeassistant/getDomainSummary.ts +0 -29
- package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
- package/src/tools/homeassistant/getHistory.ts +0 -24
- package/src/tools/homeassistant/getState.ts +0 -24
- package/src/tools/homeassistant/getStates.ts +0 -46
- package/src/tools/homeassistant/getVersion.ts +0 -21
- package/src/tools/homeassistant/index.ts +0 -121
- package/src/tools/homeassistant/listAutomations.ts +0 -35
- package/src/tools/homeassistant/listEntities.ts +0 -44
- package/src/tools/homeassistant/listPersons.ts +0 -46
- package/src/tools/homeassistant/sceneAndScript.ts +0 -53
- package/src/tools/homeassistant/searchEntities.ts +0 -58
- package/src/tools/homeassistant/sendNotification.ts +0 -179
- package/src/tools/homeassistant/systemTools.ts +0 -52
- package/src/tools/homeassistant/triggerAutomation.ts +0 -24
- package/src/tools/index.ts +0 -78
- package/src/tools/infra/actions.ts +0 -95
- package/src/tools/infra/index.ts +0 -138
- package/src/tools/omada/blockClient.ts +0 -28
- package/src/tools/omada/cyclePoePort.ts +0 -32
- package/src/tools/omada/deleteClient.ts +0 -27
- package/src/tools/omada/disableClientRateLimit.ts +0 -23
- package/src/tools/omada/getClient.ts +0 -14
- package/src/tools/omada/getDevice.ts +0 -14
- package/src/tools/omada/getFirewallSetting.ts +0 -14
- package/src/tools/omada/getInternetInfo.ts +0 -14
- package/src/tools/omada/getLanNetworkList.ts +0 -14
- package/src/tools/omada/getLanProfileList.ts +0 -14
- package/src/tools/omada/getPortForwardingStatus.ts +0 -26
- package/src/tools/omada/getRateLimitProfiles.ts +0 -14
- package/src/tools/omada/getSiteNtpStatus.ts +0 -14
- package/src/tools/omada/getSsidDetail.ts +0 -22
- package/src/tools/omada/getSsidList.ts +0 -21
- package/src/tools/omada/getSwitchStackDetail.ts +0 -14
- package/src/tools/omada/getThreatList.ts +0 -42
- package/src/tools/omada/getWlanGroupList.ts +0 -14
- package/src/tools/omada/graph.ts +0 -187
- package/src/tools/omada/index.ts +0 -153
- package/src/tools/omada/listBlockedClients.ts +0 -17
- package/src/tools/omada/listClients.ts +0 -14
- package/src/tools/omada/listClientsActivity.ts +0 -25
- package/src/tools/omada/listClientsPastConnections.ts +0 -44
- package/src/tools/omada/listDevices.ts +0 -14
- package/src/tools/omada/listDevicesStats.ts +0 -41
- package/src/tools/omada/listMostActiveClients.ts +0 -14
- package/src/tools/omada/listSites.ts +0 -18
- package/src/tools/omada/namespace.ts +0 -1164
- package/src/tools/omada/searchDevices.ts +0 -20
- package/src/tools/omada/setClientRateLimit.ts +0 -26
- package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
- package/src/tools/omada/setSiteNtpServers.ts +0 -36
- package/src/tools/omada/setSsidEnabled.ts +0 -38
- package/src/tools/omada/unblockClient.ts +0 -26
- package/src/tools/types.ts +0 -11
- package/src/tools/utils/pagination-schema.ts +0 -27
- package/src/types/ai/index.ts +0 -11
- package/src/types/ai/types.ts +0 -67
- package/src/types/homeassistant/automations.ts +0 -43
- package/src/types/homeassistant/calendars.ts +0 -26
- package/src/types/homeassistant/config.ts +0 -14
- package/src/types/homeassistant/entities.ts +0 -14
- package/src/types/homeassistant/errors.ts +0 -33
- package/src/types/homeassistant/haConfig.ts +0 -7
- package/src/types/homeassistant/history.ts +0 -13
- package/src/types/homeassistant/index.ts +0 -46
- package/src/types/homeassistant/request.ts +0 -6
- package/src/types/homeassistant/serviceCall.ts +0 -18
- package/src/types/homeassistant/states.ts +0 -58
- package/src/types/homeassistant/updates.ts +0 -28
- package/src/types/index.ts +0 -12
- package/src/types/omada/activeClientInfo.ts +0 -17
- package/src/types/omada/clientActivity.ts +0 -32
- package/src/types/omada/clientBlockStatus.ts +0 -11
- package/src/types/omada/clientPastConnection.ts +0 -82
- package/src/types/omada/index.ts +0 -14
- package/src/types/omada/omadaApiResponse.ts +0 -5
- package/src/types/omada/omadaClientInfo.ts +0 -11
- package/src/types/omada/omadaDeviceInfo.ts +0 -11
- package/src/types/omada/omadaDeviceStats.ts +0 -31
- package/src/types/omada/omadaSiteSummary.ts +0 -5
- package/src/types/omada/oswStackDetail.ts +0 -11
- package/src/types/omada/paginatedResult.ts +0 -6
- package/src/types/omada/rateLimitProfile.ts +0 -58
- package/src/types/omada/threatInfo.ts +0 -38
- package/src/types/omada/tokenResult.ts +0 -6
- package/src/types.ts +0 -2
- package/src/utils/config-validations.ts +0 -68
- package/src/utils/jwt.ts +0 -112
- package/src/utils/logger.ts +0 -121
- package/src/utils/plainText.ts +0 -48
- package/src/utils/redact.ts +0 -59
- package/src/utils/sanitizeError.ts +0 -18
- package/src/utils/tlsDispatcher.ts +0 -69
- package/src/version.ts +0 -5
- package/tests/bdd/automation-creation.spec.ts +0 -214
- package/tests/bdd/automation-management.spec.ts +0 -267
- package/tests/bdd/calendar.spec.ts +0 -190
- package/tests/bdd/configuration.spec.ts +0 -283
- package/tests/bdd/device-control.spec.ts +0 -344
- package/tests/bdd/entity-listing.spec.ts +0 -208
- package/tests/bdd/entity-search.spec.ts +0 -183
- package/tests/bdd/history-retrieval.spec.ts +0 -195
- package/tests/bdd/notifications.spec.ts +0 -238
- package/tests/bdd/service-calls.spec.ts +0 -144
- package/tests/bdd/state-retrieval.spec.ts +0 -204
- package/tests/bench/hotpaths.bench.ts +0 -68
- package/tests/config.test.ts +0 -355
- package/tests/features/automation-creation.feature +0 -36
- package/tests/features/automation-management.feature +0 -54
- package/tests/features/calendar.feature +0 -37
- package/tests/features/configuration.feature +0 -70
- package/tests/features/device-control.feature +0 -100
- package/tests/features/entity-listing.feature +0 -51
- package/tests/features/entity-search.feature +0 -44
- package/tests/features/history-retrieval.feature +0 -38
- package/tests/features/notifications.feature +0 -37
- package/tests/features/service-calls.feature +0 -35
- package/tests/features/state-retrieval.feature +0 -37
- package/tests/fixtures/openapi-default-slice.json +0 -290
- package/tests/haClient/automationTrace.test.ts +0 -59
- package/tests/haClient/automations.test.ts +0 -149
- package/tests/haClient/config.test.ts +0 -117
- package/tests/haClient/entities.test.ts +0 -188
- package/tests/haClient/events.test.ts +0 -455
- package/tests/haClient/eventsTls.test.ts +0 -52
- package/tests/haClient/history.test.ts +0 -228
- package/tests/haClient/index.test.ts +0 -243
- package/tests/haClient/request.test.ts +0 -217
- package/tests/haClient/services.test.ts +0 -120
- package/tests/haClient/states.test.ts +0 -119
- package/tests/haClient/updates.test.ts +0 -193
- package/tests/index.test.ts +0 -160
- package/tests/integration/homeassistant.test.ts +0 -144
- package/tests/integration/omada.test.ts +0 -167
- package/tests/integration/server.test.ts +0 -151
- package/tests/localAI/index.test.ts +0 -177
- package/tests/localAI/ollama.test.ts +0 -187
- package/tests/localAI/openai.test.ts +0 -257
- package/tests/omadaClient/actions.test.ts +0 -161
- package/tests/omadaClient/clientBlocks.test.ts +0 -170
- package/tests/omadaClient/ntp.test.ts +0 -381
- package/tests/omadaClient/site.test.ts +0 -37
- package/tests/ownerQuestions/service.test.ts +0 -499
- package/tests/ownerQuestions/tools.test.ts +0 -186
- package/tests/permissions/index.test.ts +0 -217
- package/tests/resources/index.test.ts +0 -288
- package/tests/scripts/generateJwt.test.ts +0 -44
- package/tests/security/accessControl.test.ts +0 -223
- package/tests/security/authentication.test.ts +0 -117
- package/tests/security/configuration.test.ts +0 -170
- package/tests/security/cryptoTransport.test.ts +0 -172
- package/tests/security/dependencies.test.ts +0 -152
- package/tests/security/injection.test.ts +0 -117
- package/tests/security/insecureDesign.test.ts +0 -121
- package/tests/security/logging.test.ts +0 -131
- package/tests/security/resourceConsumption.test.ts +0 -142
- package/tests/server/auth.test.ts +0 -249
- package/tests/server/chatFace.test.ts +0 -320
- package/tests/server/common.test.ts +0 -84
- package/tests/server/http.test.ts +0 -561
- package/tests/server/instructions.test.ts +0 -152
- package/tests/server/memoizeJsonSchema.test.ts +0 -71
- package/tests/server/stdio.test.ts +0 -99
- package/tests/server/stream.test.ts +0 -285
- package/tests/server/toolVisibility.http.test.ts +0 -111
- package/tests/server/toolVisibility.test.ts +0 -129
- package/tests/tools/ai/analyzeSensors.test.ts +0 -227
- package/tests/tools/common.test.ts +0 -123
- package/tests/tools/homeassistant/handlers.test.ts +0 -872
- package/tests/tools/infra/invokeAction.test.ts +0 -280
- package/tests/tools/omada/graph.test.ts +0 -385
- package/tests/tools/omada/handlers.test.ts +0 -897
- package/tests/tools/omada/namespace.test.ts +0 -483
- package/tests/tools/registry.test.ts +0 -168
- package/tests/utils/config-validations.test.ts +0 -352
- package/tests/utils/jwt.test.ts +0 -115
- package/tests/utils/logger.test.ts +0 -327
- package/tests/utils/redact.test.ts +0 -105
- package/tsconfig.json +0 -24
- 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
|
-
});
|