@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,499 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OwnerQuestionService: signed, restart-proof owner questions answered by a tap
|
|
3
|
-
* on an actionable phone notification.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
-
|
|
8
|
-
import { OwnerQuestionService, ACTION_EVENT, LIMITS, type ActionEvent, type AskOwnerInput } from '../../src/ownerQuestions/service.js';
|
|
9
|
-
import { logger } from '../../src/utils/logger.js';
|
|
10
|
-
|
|
11
|
-
const SECRET = 'a'.repeat(40);
|
|
12
|
-
const T0 = Date.parse('2026-10-07T08:00:00Z');
|
|
13
|
-
|
|
14
|
-
const input = (overrides: Partial<AskOwnerInput> = {}): AskOwnerInput => ({
|
|
15
|
-
question: 'Turn on the bathroom light?',
|
|
16
|
-
title: 'Harvey asks',
|
|
17
|
-
buttons: ['Approve', 'Deny'],
|
|
18
|
-
target: 'mobile_app_phone_user5',
|
|
19
|
-
timeoutSeconds: 600,
|
|
20
|
-
...overrides,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
interface Harness {
|
|
24
|
-
service: OwnerQuestionService;
|
|
25
|
-
notify: ReturnType<typeof vi.fn>;
|
|
26
|
-
subscribe: ReturnType<typeof vi.fn>;
|
|
27
|
-
/** The tap handler the service registered. */
|
|
28
|
-
tap: (event: ActionEvent) => void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function harness(secret: string | null = SECRET): Promise<Harness> {
|
|
32
|
-
const notify = vi.fn().mockResolvedValue([]);
|
|
33
|
-
let handler: ((event: ActionEvent) => void) | undefined;
|
|
34
|
-
const subscribe = vi.fn(async (_type: string, cb: (event: ActionEvent) => void) => {
|
|
35
|
-
handler = cb;
|
|
36
|
-
return 'sub-1';
|
|
37
|
-
});
|
|
38
|
-
const service = new OwnerQuestionService({ notify, subscribe, secret: secret ?? undefined, now: () => Date.now() });
|
|
39
|
-
await service.start();
|
|
40
|
-
return { service, notify, subscribe, tap: (event) => handler!(event) };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The action ids of the notification the service sent for a question. */
|
|
44
|
-
function sentActionIds(notify: ReturnType<typeof vi.fn>, call = 0): string[] {
|
|
45
|
-
const data = notify.mock.calls[call][1].data as { actions: Array<{ action: string }> };
|
|
46
|
-
return data.actions.map((a) => a.action);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const phoneTap = (action: string, overrides: Partial<ActionEvent> = {}): ActionEvent => ({
|
|
50
|
-
event_type: ACTION_EVENT,
|
|
51
|
-
data: { action },
|
|
52
|
-
origin: 'REMOTE',
|
|
53
|
-
context: { user_id: 'owner-user-id' },
|
|
54
|
-
...overrides,
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe('OwnerQuestionService', () => {
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
vi.useFakeTimers();
|
|
60
|
-
vi.setSystemTime(T0);
|
|
61
|
-
});
|
|
62
|
-
afterEach(() => {
|
|
63
|
-
vi.useRealTimers();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('does not start when Home Assistant refuses the tap subscription', async () => {
|
|
67
|
-
const service = new OwnerQuestionService({
|
|
68
|
-
notify: vi.fn(),
|
|
69
|
-
subscribe: vi.fn().mockRejectedValue(new Error("Home Assistant rejected 'subscribe_events' (unauthorized)")),
|
|
70
|
-
secret: SECRET,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
await expect(service.start()).rejects.toThrow(/unauthorized/);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('becomes unavailable (and says why) if the subscription is refused later', async () => {
|
|
77
|
-
let onRejected!: (error: Error) => void;
|
|
78
|
-
const notify = vi.fn().mockResolvedValue([]);
|
|
79
|
-
const service = new OwnerQuestionService({
|
|
80
|
-
notify,
|
|
81
|
-
subscribe: async (_type, _cb, rejected) => (onRejected = rejected),
|
|
82
|
-
secret: SECRET,
|
|
83
|
-
});
|
|
84
|
-
await service.start();
|
|
85
|
-
|
|
86
|
-
onRejected(new Error('refused'));
|
|
87
|
-
|
|
88
|
-
await expect(service.ask(input(), 'harvey')).rejects.toThrow(/unavailable.*admin Home Assistant token/);
|
|
89
|
-
expect(notify).not.toHaveBeenCalled();
|
|
90
|
-
service.stop();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('subscribes to mobile_app_notification_action on start', async () => {
|
|
94
|
-
const { subscribe } = await harness();
|
|
95
|
-
expect(subscribe).toHaveBeenCalledWith(ACTION_EVENT, expect.any(Function), expect.any(Function));
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('sends a sticky, persistent, self-expiring notification with one signed action per button', async () => {
|
|
99
|
-
const { service, notify } = await harness();
|
|
100
|
-
|
|
101
|
-
const { requestId, expiresAt } = await service.ask(input(), 'harvey');
|
|
102
|
-
|
|
103
|
-
expect(expiresAt).toBe('2026-10-07T08:10:00.000Z');
|
|
104
|
-
expect(notify).toHaveBeenCalledTimes(1);
|
|
105
|
-
const [target, serviceData] = notify.mock.calls[0];
|
|
106
|
-
expect(target).toBe('mobile_app_phone_user5');
|
|
107
|
-
expect(serviceData).toMatchObject({
|
|
108
|
-
title: 'Harvey asks',
|
|
109
|
-
message: 'Turn on the bathroom light?',
|
|
110
|
-
data: { sticky: true, persistent: true, priority: 'high', timeout: 600, tag: expect.stringMatching(/^mcpask-/) },
|
|
111
|
-
});
|
|
112
|
-
const actions = serviceData.data.actions as Array<{ action: string; title: string }>;
|
|
113
|
-
expect(actions.map((a) => a.title)).toEqual(['Approve', 'Deny']);
|
|
114
|
-
for (const [index, { action }] of actions.entries()) {
|
|
115
|
-
expect(action).toMatch(new RegExp(`^MCPASK\\.[A-Za-z0-9_-]{22}\\.${index}\\.\\d+\\.mobile_app_phone_user5\\.[A-Za-z0-9_-]{22}$`));
|
|
116
|
-
}
|
|
117
|
-
// The requestId does not contain any button's action id (it can't be used to forge a tap).
|
|
118
|
-
for (const { action } of actions) {
|
|
119
|
-
expect(requestId).not.toContain(action.split('.').at(-1));
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('returns pending, then the tapped button with time and HA user', async () => {
|
|
124
|
-
const { service, notify, tap } = await harness();
|
|
125
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
126
|
-
|
|
127
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
|
|
128
|
-
|
|
129
|
-
vi.setSystemTime(T0 + 42_000);
|
|
130
|
-
tap(phoneTap(sentActionIds(notify)[1]));
|
|
131
|
-
|
|
132
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toEqual({
|
|
133
|
-
status: 'answered',
|
|
134
|
-
index: 1,
|
|
135
|
-
button: 'Deny',
|
|
136
|
-
answeredAt: '2026-10-07T08:00:42.000Z',
|
|
137
|
-
answeredBy: 'owner-user-id',
|
|
138
|
-
question: 'Turn on the bathroom light?',
|
|
139
|
-
buttons: ['Approve', 'Deny'],
|
|
140
|
-
});
|
|
141
|
-
// The question on the phone is replaced (same tag, no buttons, dismissible).
|
|
142
|
-
const replacement = notify.mock.calls.at(-1)!;
|
|
143
|
-
expect(replacement[1]).toMatchObject({ message: 'Answered: Deny', data: { sticky: false, persistent: false } });
|
|
144
|
-
expect(replacement[1].data.actions).toBeUndefined();
|
|
145
|
-
expect(replacement[1].data.tag).toBe(notify.mock.calls[0][1].data.tag);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('a waiting getAnswer returns as soon as the tap arrives', async () => {
|
|
149
|
-
const { service, notify, tap } = await harness();
|
|
150
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
151
|
-
|
|
152
|
-
const answer = service.getAnswer(requestId, 'harvey', 50);
|
|
153
|
-
await vi.advanceTimersByTimeAsync(5_000);
|
|
154
|
-
tap(phoneTap(sentActionIds(notify)[0]));
|
|
155
|
-
|
|
156
|
-
await expect(answer).resolves.toMatchObject({ status: 'answered', index: 0, button: 'Approve' });
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('a waiting getAnswer returns pending after waitSeconds', async () => {
|
|
160
|
-
const { service } = await harness();
|
|
161
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
162
|
-
|
|
163
|
-
const answer = service.getAnswer(requestId, 'harvey', 30);
|
|
164
|
-
await vi.advanceTimersByTimeAsync(30_000);
|
|
165
|
-
|
|
166
|
-
await expect(answer).resolves.toMatchObject({ status: 'pending' });
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('the first tap wins', async () => {
|
|
170
|
-
const { service, notify, tap } = await harness();
|
|
171
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
172
|
-
const [approve, deny] = sentActionIds(notify);
|
|
173
|
-
|
|
174
|
-
tap(phoneTap(deny));
|
|
175
|
-
tap(phoneTap(approve));
|
|
176
|
-
|
|
177
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ index: 1, button: 'Deny' });
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('times out as its own status, and replaces the notification at expiry', async () => {
|
|
181
|
-
const { service, notify } = await harness();
|
|
182
|
-
const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
183
|
-
|
|
184
|
-
await vi.advanceTimersByTimeAsync(60_000);
|
|
185
|
-
|
|
186
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({
|
|
187
|
-
status: 'timeout',
|
|
188
|
-
expiredAt: '2026-10-07T08:01:00.000Z',
|
|
189
|
-
});
|
|
190
|
-
expect(notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Expired - treated as no' });
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('ignores a tap that arrives after expiry', async () => {
|
|
194
|
-
const { service, notify, tap } = await harness();
|
|
195
|
-
const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
196
|
-
const approve = sentActionIds(notify)[0];
|
|
197
|
-
|
|
198
|
-
vi.setSystemTime(T0 + 61_000);
|
|
199
|
-
tap(phoneTap(approve));
|
|
200
|
-
|
|
201
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('updates the phone at the signed expiry even when delivery was slow', async () => {
|
|
205
|
-
const { service, notify } = await harness();
|
|
206
|
-
notify.mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve([]), 30_000)));
|
|
207
|
-
|
|
208
|
-
const asking = service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
209
|
-
await vi.advanceTimersByTimeAsync(30_000); // Home Assistant took 30 s
|
|
210
|
-
await asking;
|
|
211
|
-
await vi.advanceTimersByTimeAsync(30_000); // now exactly at the signed expiry
|
|
212
|
-
|
|
213
|
-
expect(notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Expired - treated as no' });
|
|
214
|
-
service.stop();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('a tap at the exact expiry moment does not count', async () => {
|
|
218
|
-
const { service, notify, tap } = await harness();
|
|
219
|
-
const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
220
|
-
const approve = sentActionIds(notify)[0];
|
|
221
|
-
|
|
222
|
-
vi.setSystemTime(T0 + 60_000);
|
|
223
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
|
|
224
|
-
tap(phoneTap(approve));
|
|
225
|
-
|
|
226
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('a getAnswer that is waiting returns timeout when the question expires', async () => {
|
|
230
|
-
const { service } = await harness();
|
|
231
|
-
const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
232
|
-
await vi.advanceTimersByTimeAsync(50_000);
|
|
233
|
-
|
|
234
|
-
const answer = service.getAnswer(requestId, 'harvey', 50);
|
|
235
|
-
await vi.advanceTimersByTimeAsync(10_000);
|
|
236
|
-
|
|
237
|
-
await expect(answer).resolves.toMatchObject({ status: 'timeout' });
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('cancel replaces the notification, and later taps do not count', async () => {
|
|
241
|
-
const { service, notify, tap } = await harness();
|
|
242
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
243
|
-
const approve = sentActionIds(notify)[0];
|
|
244
|
-
|
|
245
|
-
await expect(service.cancel(requestId, 'harvey')).resolves.toMatchObject({ status: 'cancelled' });
|
|
246
|
-
expect(notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Cancelled - no answer needed' });
|
|
247
|
-
|
|
248
|
-
tap(phoneTap(approve));
|
|
249
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'cancelled' });
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('cancel after an answer reports the answer and does not overwrite it', async () => {
|
|
253
|
-
const { service, notify, tap } = await harness();
|
|
254
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
255
|
-
tap(phoneTap(sentActionIds(notify)[0]));
|
|
256
|
-
|
|
257
|
-
await expect(service.cancel(requestId, 'harvey')).resolves.toMatchObject({ status: 'answered', index: 0 });
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
describe('forgery and tampering', () => {
|
|
261
|
-
it('ignores a tap that did not come from a mobile app (origin LOCAL, e.g. an automation)', async () => {
|
|
262
|
-
const { service, notify, tap } = await harness();
|
|
263
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
264
|
-
|
|
265
|
-
tap(phoneTap(sentActionIds(notify)[0], { origin: 'LOCAL' }));
|
|
266
|
-
tap(phoneTap(sentActionIds(notify)[0], { context: { user_id: null } }));
|
|
267
|
-
|
|
268
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('with an HA user allowlist, only those users can answer', async () => {
|
|
272
|
-
const notify = vi.fn().mockResolvedValue([]);
|
|
273
|
-
let tapHandler!: (event: ActionEvent) => void;
|
|
274
|
-
const service = new OwnerQuestionService({
|
|
275
|
-
notify,
|
|
276
|
-
subscribe: async (_t, cb) => (tapHandler = cb),
|
|
277
|
-
secret: SECRET,
|
|
278
|
-
allowedUserIds: ['owner-user-id'],
|
|
279
|
-
});
|
|
280
|
-
await service.start();
|
|
281
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
282
|
-
const approve = sentActionIds(notify)[0];
|
|
283
|
-
|
|
284
|
-
tapHandler(phoneTap(approve, { context: { user_id: 'other-household-member' } }));
|
|
285
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
|
|
286
|
-
|
|
287
|
-
tapHandler(phoneTap(approve, { context: { user_id: 'owner-user-id' } }));
|
|
288
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'answered', answeredBy: 'owner-user-id' });
|
|
289
|
-
service.stop();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('ignores an action id with a wrong signature or a changed button index', async () => {
|
|
293
|
-
const { service, notify, tap } = await harness();
|
|
294
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
295
|
-
const deny = sentActionIds(notify)[1];
|
|
296
|
-
const parts = deny.split('.');
|
|
297
|
-
|
|
298
|
-
tap(phoneTap([...parts.slice(0, 5), 'A'.repeat(22)].join('.')));
|
|
299
|
-
tap(phoneTap([parts[0], parts[1], '0', ...parts.slice(3)].join('.'))); // Deny's signature, Approve's index
|
|
300
|
-
|
|
301
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('ignores an action id with an extended expiry', async () => {
|
|
305
|
-
const { service, notify, tap } = await harness();
|
|
306
|
-
const { requestId } = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
307
|
-
const parts = sentActionIds(notify)[0].split('.');
|
|
308
|
-
|
|
309
|
-
vi.setSystemTime(T0 + 120_000);
|
|
310
|
-
tap(phoneTap([parts[0], parts[1], parts[2], String(Number(parts[3]) + 3600), ...parts.slice(4)].join('.')));
|
|
311
|
-
|
|
312
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('ignores unrelated notification actions', async () => {
|
|
316
|
-
const { service, tap } = await harness();
|
|
317
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
318
|
-
|
|
319
|
-
tap(phoneTap('HARVEY_OK_45abeb_91065ebabdd1'));
|
|
320
|
-
tap(phoneTap(undefined as never));
|
|
321
|
-
|
|
322
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it('reports unknown for a tampered or foreign requestId', async () => {
|
|
326
|
-
const { service } = await harness();
|
|
327
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
328
|
-
const [prefix, body, sig] = requestId.split('.');
|
|
329
|
-
const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
|
|
330
|
-
const forgedBody = Buffer.from(JSON.stringify({ ...payload, e: payload.e + 86400 })).toString('base64url');
|
|
331
|
-
|
|
332
|
-
for (const bad of [`${prefix}.${forgedBody}.${sig}`, 'q1.x.y', 'nonsense', '']) {
|
|
333
|
-
await expect(service.getAnswer(bad, 'harvey', 0)).resolves.toMatchObject({ status: 'unknown' });
|
|
334
|
-
}
|
|
335
|
-
const other = await harness('b'.repeat(40));
|
|
336
|
-
await expect(other.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'unknown' });
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it('only the asking client can read or cancel', async () => {
|
|
340
|
-
const { service } = await harness();
|
|
341
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
342
|
-
|
|
343
|
-
await expect(service.getAnswer(requestId, 'openwebui', 0)).rejects.toThrow(/different client/);
|
|
344
|
-
await expect(service.cancel(requestId, 'openwebui')).rejects.toThrow(/different client/);
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
it('survives a restart: a new instance with the same secret recognises the question and the tap', async () => {
|
|
349
|
-
const first = await harness();
|
|
350
|
-
const { requestId } = await first.service.ask(input(), 'harvey');
|
|
351
|
-
const approve = sentActionIds(first.notify)[0];
|
|
352
|
-
|
|
353
|
-
const second = await harness(); // same secret, empty memory
|
|
354
|
-
await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'pending' });
|
|
355
|
-
second.tap(phoneTap(approve));
|
|
356
|
-
|
|
357
|
-
await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({
|
|
358
|
-
status: 'answered',
|
|
359
|
-
button: 'Approve',
|
|
360
|
-
question: 'Turn on the bathroom light?',
|
|
361
|
-
});
|
|
362
|
-
expect(second.notify.mock.calls.at(-1)![0]).toBe('mobile_app_phone_user5');
|
|
363
|
-
expect(second.notify.mock.calls.at(-1)![1]).toMatchObject({ message: 'Answer recorded' });
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it('polling a question from before a restart leaves no state behind once it expires', async () => {
|
|
367
|
-
const first = await harness();
|
|
368
|
-
const { requestId } = await first.service.ask(input({ timeoutSeconds: 120 }), 'harvey');
|
|
369
|
-
|
|
370
|
-
const second = await harness(); // no expiry timer for this question here
|
|
371
|
-
const poll = second.service.getAnswer(requestId, 'harvey', 50);
|
|
372
|
-
await vi.advanceTimersByTimeAsync(50_000);
|
|
373
|
-
await expect(poll).resolves.toMatchObject({ status: 'pending' });
|
|
374
|
-
expect(second.service.stateSize().waiters).toBe(0);
|
|
375
|
-
|
|
376
|
-
await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000);
|
|
377
|
-
await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
|
|
378
|
-
expect(second.service.stateSize()).toEqual({ outcomes: 0, known: 0, open: 0, asked: 0, waiters: 0 });
|
|
379
|
-
first.service.stop();
|
|
380
|
-
second.service.stop();
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it('without a secret, questions do not survive a restart', async () => {
|
|
384
|
-
const first = await harness(null);
|
|
385
|
-
const { requestId } = await first.service.ask(input(), 'harvey');
|
|
386
|
-
|
|
387
|
-
const second = await harness(null);
|
|
388
|
-
await expect(second.service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'unknown' });
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it('cleans up in-memory state on its own, without further activity', async () => {
|
|
392
|
-
const { service, notify, tap } = await harness();
|
|
393
|
-
const answered = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
394
|
-
const cancelled = await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
395
|
-
await service.ask(input({ timeoutSeconds: 60 }), 'approver'); // never answered
|
|
396
|
-
tap(phoneTap(sentActionIds(notify, 0)[0]));
|
|
397
|
-
await service.cancel(cancelled.requestId, 'harvey');
|
|
398
|
-
expect(service.stateSize().outcomes).toBe(2);
|
|
399
|
-
|
|
400
|
-
// Past expiry + the one-day retention + one sweep interval; nothing else happens.
|
|
401
|
-
await vi.advanceTimersByTimeAsync(60_000 + 24 * 60 * 60 * 1000 + 10 * 60 * 1000);
|
|
402
|
-
|
|
403
|
-
expect(service.stateSize()).toEqual({ outcomes: 0, known: 0, open: 0, asked: 0, waiters: 0 });
|
|
404
|
-
await expect(service.getAnswer(answered.requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'timeout' });
|
|
405
|
-
service.stop();
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it('never passes notification error text to the caller or the logs', async () => {
|
|
409
|
-
const { service, notify, tap } = await harness();
|
|
410
|
-
const spies = (['debug', 'info', 'warn', 'error'] as const).map((level) => vi.spyOn(logger, level));
|
|
411
|
-
try {
|
|
412
|
-
notify.mockRejectedValueOnce(new Error('POST failed, Authorization: Bearer SECRET-TOKEN-1'));
|
|
413
|
-
const error = await service.ask(input(), 'harvey').catch((e: Error) => e);
|
|
414
|
-
expect((error as Error).message).toMatch(/Could not send the notification/);
|
|
415
|
-
expect((error as Error).message).not.toContain('SECRET-TOKEN-1');
|
|
416
|
-
|
|
417
|
-
// The replacement after an answer fails too.
|
|
418
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
419
|
-
notify.mockRejectedValueOnce(new Error('Authorization: Bearer SECRET-TOKEN-2'));
|
|
420
|
-
tap(phoneTap(sentActionIds(notify, 1)[0]));
|
|
421
|
-
await vi.advanceTimersByTimeAsync(0);
|
|
422
|
-
await expect(service.getAnswer(requestId, 'harvey', 0)).resolves.toMatchObject({ status: 'answered' });
|
|
423
|
-
|
|
424
|
-
for (const spy of spies) {
|
|
425
|
-
const logged = JSON.stringify(spy.mock.calls);
|
|
426
|
-
expect(logged).not.toContain('SECRET-TOKEN-1');
|
|
427
|
-
expect(logged).not.toContain('SECRET-TOKEN-2');
|
|
428
|
-
}
|
|
429
|
-
} finally {
|
|
430
|
-
for (const spy of spies) spy.mockRestore();
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
describe('rate limits', () => {
|
|
435
|
-
it(`allows at most ${LIMITS.maxOpenPerCaller} open questions per client`, async () => {
|
|
436
|
-
const { service, notify } = await harness();
|
|
437
|
-
for (let i = 0; i < LIMITS.maxOpenPerCaller; i++) {
|
|
438
|
-
await service.ask(input(), 'harvey');
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
await expect(service.ask(input(), 'harvey')).rejects.toThrow(/Too many open questions/);
|
|
442
|
-
expect(notify).toHaveBeenCalledTimes(LIMITS.maxOpenPerCaller);
|
|
443
|
-
await expect(service.ask(input(), 'approver')).resolves.toHaveProperty('requestId');
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
it('answered, cancelled and expired questions free their slot', async () => {
|
|
447
|
-
const { service, notify, tap } = await harness();
|
|
448
|
-
const a = await service.ask(input(), 'harvey');
|
|
449
|
-
await service.ask(input(), 'harvey');
|
|
450
|
-
await service.ask(input({ timeoutSeconds: 60 }), 'harvey');
|
|
451
|
-
|
|
452
|
-
tap(phoneTap(sentActionIds(notify, 0)[0]));
|
|
453
|
-
await expect(service.ask(input(), 'harvey')).resolves.toHaveProperty('requestId');
|
|
454
|
-
await service.cancel(a.requestId, 'harvey'); // already answered: no extra slot
|
|
455
|
-
await expect(service.ask(input(), 'harvey')).rejects.toThrow(/Too many open/);
|
|
456
|
-
|
|
457
|
-
await vi.advanceTimersByTimeAsync(60_000);
|
|
458
|
-
await expect(service.ask(input(), 'harvey')).resolves.toHaveProperty('requestId');
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it(`allows at most ${LIMITS.maxPerCallerPerHour} questions per client per hour`, async () => {
|
|
462
|
-
const { service } = await harness();
|
|
463
|
-
for (let i = 0; i < LIMITS.maxPerCallerPerHour; i++) {
|
|
464
|
-
const { requestId } = await service.ask(input(), 'harvey');
|
|
465
|
-
await service.cancel(requestId, 'harvey');
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
await expect(service.ask(input(), 'harvey')).rejects.toThrow(/last hour/);
|
|
469
|
-
vi.setSystemTime(T0 + 60 * 60 * 1000 + 1);
|
|
470
|
-
await expect(service.ask(input(), 'harvey')).resolves.toHaveProperty('requestId');
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
it('concurrent questions cannot exceed the open-question cap', async () => {
|
|
474
|
-
const { service, notify } = await harness();
|
|
475
|
-
let release!: () => void;
|
|
476
|
-
const gate = new Promise<void>((r) => (release = r));
|
|
477
|
-
notify.mockImplementation(async () => {
|
|
478
|
-
await gate; // slow Home Assistant
|
|
479
|
-
return [];
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
const attempts = Array.from({ length: LIMITS.maxOpenPerCaller + 2 }, () => service.ask(input(), 'harvey'));
|
|
483
|
-
release();
|
|
484
|
-
const results = await Promise.allSettled(attempts);
|
|
485
|
-
|
|
486
|
-
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(LIMITS.maxOpenPerCaller);
|
|
487
|
-
expect(notify).toHaveBeenCalledTimes(LIMITS.maxOpenPerCaller);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
it('a failed notification does not use up a slot', async () => {
|
|
491
|
-
const { service, notify } = await harness();
|
|
492
|
-
notify.mockRejectedValue(new Error('HA unreachable'));
|
|
493
|
-
|
|
494
|
-
for (let i = 0; i < LIMITS.maxOpenPerCaller + 1; i++) {
|
|
495
|
-
await expect(service.ask(input(), 'harvey')).rejects.toThrow(/Could not send the notification/);
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
});
|
|
499
|
-
});
|