@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,288 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for MCP resources
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
-
import type { McpServer } from '@modelcontextprotocol/server';
|
|
7
|
-
import type { HaClient } from '../../src/haClient/index.js';
|
|
8
|
-
import { registerAllResources } from '../../src/resources/index.js';
|
|
9
|
-
|
|
10
|
-
// Create mock server that captures registered resources
|
|
11
|
-
function createMockServer() {
|
|
12
|
-
const resources = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
|
|
13
|
-
return {
|
|
14
|
-
registerResource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler: (...args: unknown[]) => unknown) => {
|
|
15
|
-
resources.set(name, { config, handler });
|
|
16
|
-
}),
|
|
17
|
-
resources,
|
|
18
|
-
} as unknown as McpServer & { resources: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Create mock HaClient
|
|
22
|
-
function createMockClient() {
|
|
23
|
-
return {
|
|
24
|
-
getStates: vi.fn(),
|
|
25
|
-
getState: vi.fn(),
|
|
26
|
-
getEntitiesByDomain: vi.fn(),
|
|
27
|
-
searchEntities: vi.fn(),
|
|
28
|
-
} as unknown as HaClient;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe('MCP Resources', () => {
|
|
32
|
-
let server: ReturnType<typeof createMockServer>;
|
|
33
|
-
let client: ReturnType<typeof createMockClient>;
|
|
34
|
-
|
|
35
|
-
beforeEach(() => {
|
|
36
|
-
server = createMockServer();
|
|
37
|
-
client = createMockClient();
|
|
38
|
-
vi.clearAllMocks();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('Registration', () => {
|
|
42
|
-
it('should register all 5 resources', () => {
|
|
43
|
-
registerAllResources(server, client);
|
|
44
|
-
expect(server.registerResource).toHaveBeenCalledTimes(5);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('should register All Entities resource', () => {
|
|
48
|
-
registerAllResources(server, client);
|
|
49
|
-
expect(server.resources.has('All Entities')).toBe(true);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('should register Entity State resource', () => {
|
|
53
|
-
registerAllResources(server, client);
|
|
54
|
-
expect(server.resources.has('Entity State')).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should register Entity Detailed resource', () => {
|
|
58
|
-
registerAllResources(server, client);
|
|
59
|
-
expect(server.resources.has('Entity Detailed')).toBe(true);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('should register Domain Entities resource', () => {
|
|
63
|
-
registerAllResources(server, client);
|
|
64
|
-
expect(server.resources.has('Domain Entities')).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('should register Search Entities resource', () => {
|
|
68
|
-
registerAllResources(server, client);
|
|
69
|
-
expect(server.resources.has('Search Entities')).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
describe('All Entities Resource', () => {
|
|
74
|
-
it('should return entities grouped by domain', async () => {
|
|
75
|
-
const mockEntities = [
|
|
76
|
-
{ entity_id: 'light.living_room', state: 'on', attributes: { friendly_name: 'Living Room Light' } },
|
|
77
|
-
{ entity_id: 'light.bedroom', state: 'off', attributes: { friendly_name: 'Bedroom Light' } },
|
|
78
|
-
{ entity_id: 'sensor.temperature', state: '22', attributes: { friendly_name: 'Temperature' } },
|
|
79
|
-
];
|
|
80
|
-
(client.getStates as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
|
|
81
|
-
|
|
82
|
-
registerAllResources(server, client);
|
|
83
|
-
const handler = server.resources.get('All Entities')!.handler;
|
|
84
|
-
const result = await handler();
|
|
85
|
-
|
|
86
|
-
expect(client.getStates).toHaveBeenCalled();
|
|
87
|
-
expect(result.contents).toHaveLength(1);
|
|
88
|
-
|
|
89
|
-
const content = JSON.parse(result.contents[0].text);
|
|
90
|
-
expect(content.total).toBe(3);
|
|
91
|
-
expect(content.domains).toBe(2);
|
|
92
|
-
expect(content.entities.light).toHaveLength(2);
|
|
93
|
-
expect(content.entities.sensor).toHaveLength(1);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('should handle empty entity list', async () => {
|
|
97
|
-
(client.getStates as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
98
|
-
|
|
99
|
-
registerAllResources(server, client);
|
|
100
|
-
const handler = server.resources.get('All Entities')!.handler;
|
|
101
|
-
const result = await handler();
|
|
102
|
-
|
|
103
|
-
const content = JSON.parse(result.contents[0].text);
|
|
104
|
-
expect(content.total).toBe(0);
|
|
105
|
-
expect(content.domains).toBe(0);
|
|
106
|
-
expect(content.entities).toEqual({});
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('Entity State Resource', () => {
|
|
111
|
-
it('should return entity state', async () => {
|
|
112
|
-
const mockEntity = {
|
|
113
|
-
entity_id: 'light.living_room',
|
|
114
|
-
state: 'on',
|
|
115
|
-
attributes: { friendly_name: 'Living Room Light', brightness: 255 },
|
|
116
|
-
last_changed: '2024-01-01T00:00:00Z',
|
|
117
|
-
};
|
|
118
|
-
(client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntity);
|
|
119
|
-
|
|
120
|
-
registerAllResources(server, client);
|
|
121
|
-
const handler = server.resources.get('Entity State')!.handler;
|
|
122
|
-
const uri = new URL('hass://entities/light.living_room');
|
|
123
|
-
const result = await handler(uri, { entity_id: 'light.living_room' });
|
|
124
|
-
|
|
125
|
-
expect(client.getState).toHaveBeenCalledWith('light.living_room');
|
|
126
|
-
expect(result.contents).toHaveLength(1);
|
|
127
|
-
|
|
128
|
-
const content = JSON.parse(result.contents[0].text);
|
|
129
|
-
expect(content.entity_id).toBe('light.living_room');
|
|
130
|
-
expect(content.state).toBe('on');
|
|
131
|
-
expect(content.friendly_name).toBe('Living Room Light');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('should return error for non-existent entity', async () => {
|
|
135
|
-
(client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
136
|
-
|
|
137
|
-
registerAllResources(server, client);
|
|
138
|
-
const handler = server.resources.get('Entity State')!.handler;
|
|
139
|
-
const uri = new URL('hass://entities/light.nonexistent');
|
|
140
|
-
const result = await handler(uri, { entity_id: 'light.nonexistent' });
|
|
141
|
-
|
|
142
|
-
const content = JSON.parse(result.contents[0].text);
|
|
143
|
-
expect(content.error).toBe('Entity not found');
|
|
144
|
-
expect(content.entity_id).toBe('light.nonexistent');
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
describe('Entity Detailed Resource', () => {
|
|
149
|
-
it('should return detailed entity info', async () => {
|
|
150
|
-
const mockEntity = {
|
|
151
|
-
entity_id: 'light.living_room',
|
|
152
|
-
state: 'on',
|
|
153
|
-
attributes: { friendly_name: 'Living Room Light', brightness: 255, supported_features: 1 },
|
|
154
|
-
last_changed: '2024-01-01T00:00:00Z',
|
|
155
|
-
last_updated: '2024-01-01T00:00:01Z',
|
|
156
|
-
context: { id: 'abc123', parent_id: null, user_id: null },
|
|
157
|
-
};
|
|
158
|
-
(client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntity);
|
|
159
|
-
|
|
160
|
-
registerAllResources(server, client);
|
|
161
|
-
const handler = server.resources.get('Entity Detailed')!.handler;
|
|
162
|
-
const uri = new URL('hass://entities/light.living_room/detailed');
|
|
163
|
-
const result = await handler(uri, { entity_id: 'light.living_room' });
|
|
164
|
-
|
|
165
|
-
const content = JSON.parse(result.contents[0].text);
|
|
166
|
-
expect(content.entity_id).toBe('light.living_room');
|
|
167
|
-
expect(content.attributes).toHaveProperty('brightness');
|
|
168
|
-
expect(content.attributes).toHaveProperty('supported_features');
|
|
169
|
-
expect(content.last_updated).toBeDefined();
|
|
170
|
-
expect(content.context).toBeDefined();
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('should return error for non-existent entity', async () => {
|
|
174
|
-
(client.getState as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
|
175
|
-
|
|
176
|
-
registerAllResources(server, client);
|
|
177
|
-
const handler = server.resources.get('Entity Detailed')!.handler;
|
|
178
|
-
const uri = new URL('hass://entities/light.nonexistent/detailed');
|
|
179
|
-
const result = await handler(uri, { entity_id: 'light.nonexistent' });
|
|
180
|
-
|
|
181
|
-
const content = JSON.parse(result.contents[0].text);
|
|
182
|
-
expect(content.error).toBe('Entity not found');
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
describe('Domain Entities Resource', () => {
|
|
187
|
-
it('should return all entities for a domain', async () => {
|
|
188
|
-
const mockEntities = [
|
|
189
|
-
{ entity_id: 'light.living_room', state: 'on', attributes: { friendly_name: 'Living Room' } },
|
|
190
|
-
{ entity_id: 'light.bedroom', state: 'off', attributes: { friendly_name: 'Bedroom' } },
|
|
191
|
-
];
|
|
192
|
-
(client.getEntitiesByDomain as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
|
|
193
|
-
|
|
194
|
-
registerAllResources(server, client);
|
|
195
|
-
const handler = server.resources.get('Domain Entities')!.handler;
|
|
196
|
-
const uri = new URL('hass://entities/domain/light');
|
|
197
|
-
const result = await handler(uri, { domain: 'light' });
|
|
198
|
-
|
|
199
|
-
expect(client.getEntitiesByDomain).toHaveBeenCalledWith('light');
|
|
200
|
-
|
|
201
|
-
const content = JSON.parse(result.contents[0].text);
|
|
202
|
-
expect(content.domain).toBe('light');
|
|
203
|
-
expect(content.count).toBe(2);
|
|
204
|
-
expect(content.entities).toHaveLength(2);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('should handle domain with no entities', async () => {
|
|
208
|
-
(client.getEntitiesByDomain as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
209
|
-
|
|
210
|
-
registerAllResources(server, client);
|
|
211
|
-
const handler = server.resources.get('Domain Entities')!.handler;
|
|
212
|
-
const uri = new URL('hass://entities/domain/vacuum');
|
|
213
|
-
const result = await handler(uri, { domain: 'vacuum' });
|
|
214
|
-
|
|
215
|
-
const content = JSON.parse(result.contents[0].text);
|
|
216
|
-
expect(content.domain).toBe('vacuum');
|
|
217
|
-
expect(content.count).toBe(0);
|
|
218
|
-
expect(content.entities).toEqual([]);
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
describe('Search Entities Resource', () => {
|
|
223
|
-
it('should search entities with query and limit', async () => {
|
|
224
|
-
const mockEntities = [
|
|
225
|
-
{ entity_id: 'light.living_room', state: 'on', attributes: { friendly_name: 'Living Room Light' } },
|
|
226
|
-
{ entity_id: 'light.living_lamp', state: 'off', attributes: { friendly_name: 'Living Lamp' } },
|
|
227
|
-
];
|
|
228
|
-
(client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
|
|
229
|
-
|
|
230
|
-
registerAllResources(server, client);
|
|
231
|
-
const handler = server.resources.get('Search Entities')!.handler;
|
|
232
|
-
const uri = new URL('hass://search/living/10');
|
|
233
|
-
const result = await handler(uri, { query: 'living', limit: '10' });
|
|
234
|
-
|
|
235
|
-
expect(client.searchEntities).toHaveBeenCalledWith('living');
|
|
236
|
-
|
|
237
|
-
const content = JSON.parse(result.contents[0].text);
|
|
238
|
-
expect(content.query).toBe('living');
|
|
239
|
-
expect(content.limit).toBe(10);
|
|
240
|
-
expect(content.count).toBe(2);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('should apply limit to search results', async () => {
|
|
244
|
-
const mockEntities = Array.from({ length: 100 }, (_, i) => ({
|
|
245
|
-
entity_id: `sensor.test_${i}`,
|
|
246
|
-
state: 'on',
|
|
247
|
-
attributes: { friendly_name: `Test ${i}` },
|
|
248
|
-
}));
|
|
249
|
-
(client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue(mockEntities);
|
|
250
|
-
|
|
251
|
-
registerAllResources(server, client);
|
|
252
|
-
const handler = server.resources.get('Search Entities')!.handler;
|
|
253
|
-
const uri = new URL('hass://search/test/5');
|
|
254
|
-
const result = await handler(uri, { query: 'test', limit: '5' });
|
|
255
|
-
|
|
256
|
-
const content = JSON.parse(result.contents[0].text);
|
|
257
|
-
expect(content.count).toBe(5);
|
|
258
|
-
expect(content.entities).toHaveLength(5);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('should decode URL-encoded query', async () => {
|
|
262
|
-
(client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
263
|
-
|
|
264
|
-
registerAllResources(server, client);
|
|
265
|
-
const handler = server.resources.get('Search Entities')!.handler;
|
|
266
|
-
const uri = new URL('hass://search/living%20room/50');
|
|
267
|
-
const result = await handler(uri, { query: 'living%20room', limit: '50' });
|
|
268
|
-
|
|
269
|
-
expect(client.searchEntities).toHaveBeenCalledWith('living room');
|
|
270
|
-
|
|
271
|
-
const content = JSON.parse(result.contents[0].text);
|
|
272
|
-
expect(content.query).toBe('living room');
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it('should use default limit when not specified', async () => {
|
|
276
|
-
(client.searchEntities as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
|
277
|
-
|
|
278
|
-
registerAllResources(server, client);
|
|
279
|
-
const handler = server.resources.get('Search Entities')!.handler;
|
|
280
|
-
const uri = new URL('hass://search/test/50');
|
|
281
|
-
// Simulate undefined limit
|
|
282
|
-
const result = await handler(uri, { query: 'test', limit: undefined });
|
|
283
|
-
|
|
284
|
-
const content = JSON.parse(result.contents[0].text);
|
|
285
|
-
expect(content.limit).toBe(50);
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Output contract of scripts/generate-jwt.ts: stdout is exactly one bare JWT.
|
|
3
|
-
*
|
|
4
|
-
* Callers capture it with `TOKEN=$(npm run -s generate:jwt -- ...)`. A banner or a
|
|
5
|
-
* second copy of the token on stdout (the pre-1.7.5 behavior) corrupts that capture,
|
|
6
|
-
* so this test runs the real script as a subprocess and checks both streams.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, it, expect } from 'vitest';
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
11
|
-
import { resolve } from 'node:path';
|
|
12
|
-
import { verifyJwt } from '../../src/utils/jwt.js';
|
|
13
|
-
|
|
14
|
-
const ROOT = resolve(__dirname, '../..');
|
|
15
|
-
const TSX_CLI = resolve(ROOT, 'node_modules/tsx/dist/cli.mjs');
|
|
16
|
-
const SCRIPT = resolve(ROOT, 'scripts/generate-jwt.ts');
|
|
17
|
-
const SECRET = 'dummy-secret-for-generate-jwt-test-0123456789';
|
|
18
|
-
|
|
19
|
-
function run(args: string[]): { status: number | null; stdout: string; stderr: string } {
|
|
20
|
-
const result = spawnSync(process.execPath, [TSX_CLI, SCRIPT, ...args], {
|
|
21
|
-
cwd: ROOT,
|
|
22
|
-
// Never inherit a real secret from the developer's environment.
|
|
23
|
-
env: { ...process.env, MCP_AUTH_SECRET: '' },
|
|
24
|
-
encoding: 'utf8',
|
|
25
|
-
});
|
|
26
|
-
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe('generate-jwt output contract', () => {
|
|
30
|
-
it('prints exactly one valid JWT on stdout; details go to stderr', () => {
|
|
31
|
-
const { status, stdout, stderr } = run(['--sub', 'contract-test', '--exp', '5m', '--secret', SECRET]);
|
|
32
|
-
expect(status).toBe(0);
|
|
33
|
-
|
|
34
|
-
const lines = stdout.split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
35
|
-
expect(lines).toHaveLength(1);
|
|
36
|
-
|
|
37
|
-
const result = verifyJwt(lines[0], SECRET);
|
|
38
|
-
expect(result.valid).toBe(true);
|
|
39
|
-
expect(result.payload?.sub).toBe('contract-test');
|
|
40
|
-
|
|
41
|
-
expect(stderr).toContain('Subject: contract-test');
|
|
42
|
-
expect(stderr).not.toContain(lines[0]);
|
|
43
|
-
}, 30_000);
|
|
44
|
-
});
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEC-AC — Access Control / Authorization
|
|
3
|
-
* OWASP A01:2021 (Broken Access Control), API1/API5:2023 (BOLA/BFLA)
|
|
4
|
-
*
|
|
5
|
-
* Findings covered:
|
|
6
|
-
* H1 — REST /api/* routes bypass RBAC (authenticated but no permission check).
|
|
7
|
-
*
|
|
8
|
-
* These tests assert the SECURE behavior: every REST route is gated on the same
|
|
9
|
-
* Permission.* bit as its MCP-tool twin, and a token lacking that bit is rejected
|
|
10
|
-
* with 403 BEFORE any Home Assistant call is made.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
15
|
-
import type { HaClient } from '../../src/haClient/index.js';
|
|
16
|
-
import { Permission } from '../../src/permissions/index.js';
|
|
17
|
-
import { requiredRestPermission, handleRestApi } from '../../src/server/http.js';
|
|
18
|
-
import { handleEventSubscription, getClientCount } from '../../src/server/eventSubscription.js';
|
|
19
|
-
import type { EventSubscriber } from '../../src/haClient/events.js';
|
|
20
|
-
import type { AuthenticatedRequest } from '../../src/server/auth.js';
|
|
21
|
-
|
|
22
|
-
/** Build a mock request carrying a permission mask in auth.extra. */
|
|
23
|
-
function mockReq(
|
|
24
|
-
method: string,
|
|
25
|
-
url: string,
|
|
26
|
-
permissions: number,
|
|
27
|
-
body?: unknown
|
|
28
|
-
): AuthenticatedRequest {
|
|
29
|
-
const listeners: Record<string, ((arg?: unknown) => void)[]> = {};
|
|
30
|
-
const req = {
|
|
31
|
-
method,
|
|
32
|
-
url,
|
|
33
|
-
headers: { host: 'mcp.test:3000' },
|
|
34
|
-
auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
|
|
35
|
-
on(event: string, cb: (arg?: unknown) => void) {
|
|
36
|
-
(listeners[event] ??= []).push(cb);
|
|
37
|
-
// Drive the body stream synchronously on the next tick for POST parseBody().
|
|
38
|
-
if (event === 'end') {
|
|
39
|
-
queueMicrotask(() => {
|
|
40
|
-
if (body !== undefined) {
|
|
41
|
-
for (const d of listeners['data'] ?? []) d(Buffer.from(JSON.stringify(body)));
|
|
42
|
-
}
|
|
43
|
-
for (const e of listeners['end'] ?? []) e();
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
return req;
|
|
47
|
-
},
|
|
48
|
-
} as unknown as AuthenticatedRequest;
|
|
49
|
-
return req;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Capture status code and JSON body written to the response. */
|
|
53
|
-
function mockRes(): ServerResponse & { _status?: number; _body?: unknown } {
|
|
54
|
-
const res = {
|
|
55
|
-
statusCode: 200,
|
|
56
|
-
headersSent: false,
|
|
57
|
-
setHeader: vi.fn(),
|
|
58
|
-
end: vi.fn(function (this: { _body?: unknown }, payload?: unknown) {
|
|
59
|
-
if (typeof payload === 'string') {
|
|
60
|
-
try {
|
|
61
|
-
this._body = JSON.parse(payload);
|
|
62
|
-
} catch {
|
|
63
|
-
this._body = payload;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return this;
|
|
67
|
-
}),
|
|
68
|
-
} as unknown as ServerResponse & { _status?: number; _body?: unknown };
|
|
69
|
-
// mirror statusCode into _status when sendJson sets it
|
|
70
|
-
Object.defineProperty(res, '_status', {
|
|
71
|
-
get() {
|
|
72
|
-
return res.statusCode;
|
|
73
|
-
},
|
|
74
|
-
configurable: true,
|
|
75
|
-
});
|
|
76
|
-
return res;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
describe('SEC-AC — Access Control', () => {
|
|
80
|
-
describe('H1: REST route → permission mapping (mirrors MCP-tool twins)', () => {
|
|
81
|
-
it('maps read routes to QUERY', () => {
|
|
82
|
-
expect(requiredRestPermission('GET', '/api/states')).toBe(Permission.QUERY);
|
|
83
|
-
expect(requiredRestPermission('GET', '/api/states/light.kitchen')).toBe(Permission.QUERY);
|
|
84
|
-
expect(requiredRestPermission('GET', '/api/sensors')).toBe(Permission.QUERY);
|
|
85
|
-
expect(requiredRestPermission('GET', '/api/entities/light')).toBe(Permission.QUERY);
|
|
86
|
-
expect(requiredRestPermission('GET', '/api/search')).toBe(Permission.QUERY);
|
|
87
|
-
expect(requiredRestPermission('GET', '/api/history/sensor.temp')).toBe(Permission.QUERY);
|
|
88
|
-
expect(requiredRestPermission('GET', '/api/version')).toBe(Permission.QUERY);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('maps the service-call route to CONTROL', () => {
|
|
92
|
-
expect(requiredRestPermission('POST', '/api/services/light/turn_on')).toBe(Permission.CONTROL);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('returns null for unknown routes (fall through to 404, no auth needed)', () => {
|
|
96
|
-
expect(requiredRestPermission('GET', '/api/unknown')).toBeNull();
|
|
97
|
-
expect(requiredRestPermission('DELETE', '/api/states')).toBeNull();
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('H1: enforcement — insufficient permission is rejected with 403', () => {
|
|
102
|
-
it('blocks GET /api/states without QUERY and never calls the client', async () => {
|
|
103
|
-
const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
|
|
104
|
-
const req = mockReq('GET', '/api/states', Permission.CONTROL); // CONTROL, not QUERY
|
|
105
|
-
const res = mockRes();
|
|
106
|
-
|
|
107
|
-
await handleRestApi(req, res, '/api/states', client);
|
|
108
|
-
|
|
109
|
-
expect(res.statusCode).toBe(403);
|
|
110
|
-
expect(client.getStates).not.toHaveBeenCalled();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('blocks POST /api/services without CONTROL and never calls the service', async () => {
|
|
114
|
-
const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
|
|
115
|
-
const req = mockReq('POST', '/api/services/light/turn_on', Permission.QUERY, {
|
|
116
|
-
entity_id: 'light.kitchen',
|
|
117
|
-
});
|
|
118
|
-
const res = mockRes();
|
|
119
|
-
|
|
120
|
-
await handleRestApi(req, res, '/api/services/light/turn_on', client);
|
|
121
|
-
|
|
122
|
-
expect(res.statusCode).toBe(403);
|
|
123
|
-
expect(client.callService).not.toHaveBeenCalled();
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('H1: enforcement — sufficient permission is allowed', () => {
|
|
128
|
-
it('allows GET /api/states with QUERY', async () => {
|
|
129
|
-
const client = { getStates: vi.fn().mockResolvedValue([{ entity_id: 'light.x' }]) } as unknown as HaClient;
|
|
130
|
-
const req = mockReq('GET', '/api/states', Permission.QUERY);
|
|
131
|
-
const res = mockRes();
|
|
132
|
-
|
|
133
|
-
await handleRestApi(req, res, '/api/states', client);
|
|
134
|
-
|
|
135
|
-
expect(res.statusCode).toBe(200);
|
|
136
|
-
expect(client.getStates).toHaveBeenCalledOnce();
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('allows POST /api/services with CONTROL', async () => {
|
|
140
|
-
const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
|
|
141
|
-
const req = mockReq('POST', '/api/services/light/turn_on', Permission.CONTROL, {
|
|
142
|
-
entity_id: 'light.kitchen',
|
|
143
|
-
});
|
|
144
|
-
const res = mockRes();
|
|
145
|
-
|
|
146
|
-
await handleRestApi(req, res, '/api/services/light/turn_on', client);
|
|
147
|
-
|
|
148
|
-
expect(res.statusCode).toBe(200);
|
|
149
|
-
expect(client.callService).toHaveBeenCalledOnce();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('treats SUPERUSER (0xFF) as allowed everywhere', async () => {
|
|
153
|
-
const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
|
|
154
|
-
const req = mockReq('GET', '/api/states', 0xff);
|
|
155
|
-
const res = mockRes();
|
|
156
|
-
|
|
157
|
-
await handleRestApi(req, res, '/api/states', client);
|
|
158
|
-
|
|
159
|
-
expect(res.statusCode).toBe(200);
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
describe('L2: SSE /subscribe_events requires QUERY permission', () => {
|
|
164
|
-
/** Minimal SSE-capable response mock. */
|
|
165
|
-
function sseRes() {
|
|
166
|
-
return {
|
|
167
|
-
writableEnded: false,
|
|
168
|
-
writeHead: vi.fn(),
|
|
169
|
-
write: vi.fn(),
|
|
170
|
-
end: vi.fn(),
|
|
171
|
-
} as unknown as ServerResponse & { writeHead: ReturnType<typeof vi.fn> };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Request mock that captures the 'close' handler so we can tear down timers. */
|
|
175
|
-
function sseReq(permissions: number) {
|
|
176
|
-
const handlers: Record<string, (arg?: unknown) => void> = {};
|
|
177
|
-
const req = {
|
|
178
|
-
url: '/subscribe_events',
|
|
179
|
-
headers: {},
|
|
180
|
-
auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
|
|
181
|
-
on(event: string, cb: (arg?: unknown) => void) {
|
|
182
|
-
handlers[event] = cb;
|
|
183
|
-
return req;
|
|
184
|
-
},
|
|
185
|
-
} as unknown as AuthenticatedRequest;
|
|
186
|
-
return { req, handlers };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
it('rejects a subscriber lacking QUERY with 403 and creates no client', async () => {
|
|
190
|
-
const before = getClientCount();
|
|
191
|
-
const { req } = sseReq(Permission.CONTROL); // CONTROL, not QUERY
|
|
192
|
-
const res = sseRes();
|
|
193
|
-
const subscriber = { isConnected: vi.fn() } as unknown as EventSubscriber;
|
|
194
|
-
|
|
195
|
-
await handleEventSubscription(req, res, subscriber);
|
|
196
|
-
|
|
197
|
-
expect(res.writeHead).toHaveBeenCalledWith(403, expect.anything());
|
|
198
|
-
expect(getClientCount()).toBe(before); // no leak
|
|
199
|
-
expect(subscriber.isConnected).not.toHaveBeenCalled();
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('allows a subscriber with QUERY to open the stream', async () => {
|
|
203
|
-
const { req, handlers } = sseReq(Permission.QUERY);
|
|
204
|
-
const res = sseRes();
|
|
205
|
-
const subscriber = {
|
|
206
|
-
isConnected: vi.fn().mockReturnValue(true),
|
|
207
|
-
subscribeEventType: vi.fn().mockResolvedValue('sub-1'),
|
|
208
|
-
unsubscribe: vi.fn().mockResolvedValue(undefined),
|
|
209
|
-
} as unknown as EventSubscriber;
|
|
210
|
-
|
|
211
|
-
await handleEventSubscription(req, res, subscriber);
|
|
212
|
-
|
|
213
|
-
// 200 stream opened, not 403
|
|
214
|
-
expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
|
|
215
|
-
'Content-Type': 'text/event-stream',
|
|
216
|
-
}));
|
|
217
|
-
expect(subscriber.subscribeEventType).toHaveBeenCalled();
|
|
218
|
-
|
|
219
|
-
// tear down the keep-alive interval the handler registered
|
|
220
|
-
handlers['close']?.();
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
});
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEC-AUTHN — Authentication & Session
|
|
3
|
-
* OWASP A07:2021 (Identification & Authentication Failures), API2:2023
|
|
4
|
-
*
|
|
5
|
-
* Findings covered:
|
|
6
|
-
* H3 — tokens without `exp` never expire; expiry is optional and unenforceable.
|
|
7
|
-
* M8 — JWT secret strength is not enforced at startup.
|
|
8
|
-
* L6 — no `iss` / `aud` / `nbf` / clock-skew validation.
|
|
9
|
-
*
|
|
10
|
-
* Secure behavior asserted here:
|
|
11
|
-
* - verifyJwt can REQUIRE exp (opt-in), validate nbf, and check iss/aud when configured.
|
|
12
|
-
* - The default (no options) stays backward-compatible: a token without exp is accepted.
|
|
13
|
-
* - loadConfig refuses a bearer secret shorter than 32 chars.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, it, expect } from 'vitest';
|
|
17
|
-
import { createJwt, verifyJwt } from '../../src/utils/jwt.js';
|
|
18
|
-
import { loadConfig } from '../../src/config.js';
|
|
19
|
-
|
|
20
|
-
const SECRET = 'test-secret-key-32chars-minimum!';
|
|
21
|
-
const now = () => Math.floor(Date.now() / 1000);
|
|
22
|
-
|
|
23
|
-
describe('SEC-AUTHN — Authentication', () => {
|
|
24
|
-
describe('H3: enforce token expiry (opt-in via requireExp)', () => {
|
|
25
|
-
it('rejects a token without exp when requireExp is set', () => {
|
|
26
|
-
const token = createJwt({ sub: 'service' }, SECRET);
|
|
27
|
-
const result = verifyJwt(token, SECRET, { requireExp: true });
|
|
28
|
-
expect(result.valid).toBe(false);
|
|
29
|
-
expect(result.error).toMatch(/exp/i);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('accepts a token with a future exp when requireExp is set', () => {
|
|
33
|
-
const token = createJwt({ sub: 'u', exp: now() + 3600 }, SECRET);
|
|
34
|
-
expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('still rejects an expired token when requireExp is set', () => {
|
|
38
|
-
const token = createJwt({ sub: 'u', exp: now() - 100 }, SECRET);
|
|
39
|
-
expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(false);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('default (no options) remains backward-compatible: accepts no-exp token', () => {
|
|
43
|
-
const token = createJwt({ sub: 'service' }, SECRET);
|
|
44
|
-
expect(verifyJwt(token, SECRET).valid).toBe(true);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe('L6: nbf (not-before) validation', () => {
|
|
49
|
-
it('rejects a token whose nbf is in the future', () => {
|
|
50
|
-
const token = createJwt({ sub: 'u', nbf: now() + 3600 }, SECRET);
|
|
51
|
-
const result = verifyJwt(token, SECRET);
|
|
52
|
-
expect(result.valid).toBe(false);
|
|
53
|
-
expect(result.error).toMatch(/not yet valid|nbf/i);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('accepts a token whose nbf is in the past', () => {
|
|
57
|
-
const token = createJwt({ sub: 'u', nbf: now() - 100 }, SECRET);
|
|
58
|
-
expect(verifyJwt(token, SECRET).valid).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('tolerates small clock skew on nbf', () => {
|
|
62
|
-
const token = createJwt({ sub: 'u', nbf: now() + 30 }, SECRET);
|
|
63
|
-
expect(verifyJwt(token, SECRET, { clockSkewSec: 60 }).valid).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('L6: issuer / audience validation (only when configured)', () => {
|
|
68
|
-
it('rejects a token with the wrong issuer', () => {
|
|
69
|
-
const token = createJwt({ sub: 'u', iss: 'evil' }, SECRET);
|
|
70
|
-
const result = verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' });
|
|
71
|
-
expect(result.valid).toBe(false);
|
|
72
|
-
expect(result.error).toMatch(/issuer/i);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('accepts a token with the expected issuer', () => {
|
|
76
|
-
const token = createJwt({ sub: 'u', iss: 'mcp-ha-connect' }, SECRET);
|
|
77
|
-
expect(verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' }).valid).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('rejects a token whose audience does not match', () => {
|
|
81
|
-
const token = createJwt({ sub: 'u', aud: 'other' }, SECRET);
|
|
82
|
-
const result = verifyJwt(token, SECRET, { audience: 'mcp' });
|
|
83
|
-
expect(result.valid).toBe(false);
|
|
84
|
-
expect(result.error).toMatch(/audience/i);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('accepts a token whose audience array contains the expected value', () => {
|
|
88
|
-
const token = createJwt({ sub: 'u', aud: ['mcp', 'other'] }, SECRET);
|
|
89
|
-
expect(verifyJwt(token, SECRET, { audience: 'mcp' }).valid).toBe(true);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('ignores iss/aud when not configured (backward-compatible)', () => {
|
|
93
|
-
const token = createJwt({ sub: 'u', iss: 'whatever', aud: 'whatever' }, SECRET);
|
|
94
|
-
expect(verifyJwt(token, SECRET).valid).toBe(true);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe('M8: bearer secret strength enforced at startup', () => {
|
|
99
|
-
const base = { MCP_SERVER_USE_HTTP: 'true', MCP_AUTH_METHOD: 'bearer' };
|
|
100
|
-
|
|
101
|
-
it('rejects a bearer secret shorter than 32 characters', () => {
|
|
102
|
-
expect(() =>
|
|
103
|
-
loadConfig({ ...base, MCP_AUTH_SECRET: 'too-short' } as NodeJS.ProcessEnv)
|
|
104
|
-
).toThrow(/32|secret/i);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('accepts a bearer secret of at least 32 characters', () => {
|
|
108
|
-
expect(() =>
|
|
109
|
-
loadConfig({ ...base, MCP_AUTH_SECRET: 'a'.repeat(32) } as NodeJS.ProcessEnv)
|
|
110
|
-
).not.toThrow();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('does not require a secret when auth method is none', () => {
|
|
114
|
-
expect(() => loadConfig({ MCP_AUTH_METHOD: 'none' } as NodeJS.ProcessEnv)).not.toThrow();
|
|
115
|
-
});
|
|
116
|
-
});
|
|
117
|
-
});
|