@coffeerunhobby/mcp-ha-connect 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +23 -5
  2. package/dist/config.d.ts +4 -0
  3. package/dist/config.js +6 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/haClient/automations.d.ts +18 -2
  6. package/dist/haClient/automations.js +25 -7
  7. package/dist/haClient/automations.js.map +1 -1
  8. package/dist/haClient/events.d.ts +60 -7
  9. package/dist/haClient/events.js +252 -47
  10. package/dist/haClient/events.js.map +1 -1
  11. package/dist/haClient/index.d.ts +2 -1
  12. package/dist/haClient/index.js +4 -3
  13. package/dist/haClient/index.js.map +1 -1
  14. package/dist/haClient/wsCommand.d.ts +16 -0
  15. package/dist/haClient/wsCommand.js +36 -0
  16. package/dist/haClient/wsCommand.js.map +1 -0
  17. package/dist/omadaClient/client.d.ts +33 -1
  18. package/dist/omadaClient/client.js +61 -2
  19. package/dist/omadaClient/client.js.map +1 -1
  20. package/dist/omadaClient/index.d.ts +7 -0
  21. package/dist/omadaClient/index.js +6 -0
  22. package/dist/omadaClient/index.js.map +1 -1
  23. package/dist/omadaClient/request.d.ts +5 -0
  24. package/dist/omadaClient/request.js +11 -2
  25. package/dist/omadaClient/request.js.map +1 -1
  26. package/dist/ownerQuestions/service.d.ts +167 -0
  27. package/dist/ownerQuestions/service.js +439 -0
  28. package/dist/ownerQuestions/service.js.map +1 -0
  29. package/dist/server/common.d.ts +3 -0
  30. package/dist/server/common.js +2 -1
  31. package/dist/server/common.js.map +1 -1
  32. package/dist/server/http.js +30 -6
  33. package/dist/server/http.js.map +1 -1
  34. package/dist/server/stream.d.ts +2 -0
  35. package/dist/server/stream.js +2 -1
  36. package/dist/server/stream.js.map +1 -1
  37. package/dist/tools/common.d.ts +1 -1
  38. package/dist/tools/common.js +9 -2
  39. package/dist/tools/common.js.map +1 -1
  40. package/dist/tools/homeassistant/askOwner.d.ts +29 -0
  41. package/dist/tools/homeassistant/askOwner.js +85 -0
  42. package/dist/tools/homeassistant/askOwner.js.map +1 -0
  43. package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
  44. package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
  45. package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
  46. package/dist/tools/index.d.ts +3 -0
  47. package/dist/tools/index.js +5 -1
  48. package/dist/tools/index.js.map +1 -1
  49. package/dist/tools/omada/blockClient.js +3 -1
  50. package/dist/tools/omada/blockClient.js.map +1 -1
  51. package/dist/tools/omada/deleteClient.d.ts +3 -0
  52. package/dist/tools/omada/deleteClient.js +15 -0
  53. package/dist/tools/omada/deleteClient.js.map +1 -0
  54. package/dist/tools/omada/index.js +7 -2
  55. package/dist/tools/omada/index.js.map +1 -1
  56. package/dist/tools/omada/listBlockedClients.d.ts +3 -0
  57. package/dist/tools/omada/listBlockedClients.js +9 -0
  58. package/dist/tools/omada/listBlockedClients.js.map +1 -0
  59. package/dist/tools/omada/namespace.js +17 -0
  60. package/dist/tools/omada/namespace.js.map +1 -1
  61. package/dist/utils/plainText.d.ts +22 -0
  62. package/dist/utils/plainText.js +39 -0
  63. package/dist/utils/plainText.js.map +1 -0
  64. package/dist/utils/redact.d.ts +21 -0
  65. package/dist/utils/redact.js +53 -0
  66. package/dist/utils/redact.js.map +1 -0
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/docs/CHANGELOG.md +24 -0
  70. package/openapi.json +1 -1
  71. package/package.json +1 -1
  72. package/src/config.ts +10 -0
  73. package/src/haClient/automations.ts +39 -7
  74. package/src/haClient/events.ts +289 -56
  75. package/src/haClient/index.ts +10 -4
  76. package/src/haClient/wsCommand.ts +44 -0
  77. package/src/omadaClient/client.ts +82 -4
  78. package/src/omadaClient/index.ts +9 -1
  79. package/src/omadaClient/request.ts +13 -2
  80. package/src/ownerQuestions/service.ts +536 -0
  81. package/src/server/common.ts +5 -1
  82. package/src/server/http.ts +30 -6
  83. package/src/server/stream.ts +4 -1
  84. package/src/tools/common.ts +9 -2
  85. package/src/tools/homeassistant/askOwner.ts +133 -0
  86. package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
  87. package/src/tools/index.ts +8 -1
  88. package/src/tools/omada/blockClient.ts +3 -1
  89. package/src/tools/omada/deleteClient.ts +27 -0
  90. package/src/tools/omada/index.ts +7 -2
  91. package/src/tools/omada/listBlockedClients.ts +17 -0
  92. package/src/tools/omada/namespace.ts +19 -0
  93. package/src/utils/plainText.ts +48 -0
  94. package/src/utils/redact.ts +59 -0
  95. package/src/version.ts +1 -1
  96. package/tests/config.test.ts +12 -0
  97. package/tests/haClient/automationTrace.test.ts +59 -0
  98. package/tests/haClient/automations.test.ts +16 -0
  99. package/tests/haClient/events.test.ts +455 -0
  100. package/tests/haClient/eventsTls.test.ts +52 -0
  101. package/tests/omadaClient/clientBlocks.test.ts +170 -0
  102. package/tests/ownerQuestions/service.test.ts +499 -0
  103. package/tests/ownerQuestions/tools.test.ts +186 -0
  104. package/tests/tools/homeassistant/handlers.test.ts +10 -1
  105. package/tests/tools/omada/graph.test.ts +13 -2
  106. package/tests/utils/redact.test.ts +105 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Unblocking clients the controller no longer treats as current, the blocked
3
+ * list, and deleting a client record (for orphaned blocks).
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import type { McpServer } from '@modelcontextprotocol/server';
8
+
9
+ import { ClientOperations, CLIENT_DOES_NOT_EXIST, normalizeMac } from '../../src/omadaClient/client.js';
10
+ import { OmadaApiError, RequestHandler } from '../../src/omadaClient/request.js';
11
+ import type { SiteOperations } from '../../src/omadaClient/site.js';
12
+ import type { OmadaClient } from '../../src/omadaClient/index.js';
13
+ import { registerDeleteClientTool } from '../../src/tools/omada/deleteClient.js';
14
+ import { registerListBlockedClientsTool } from '../../src/tools/omada/listBlockedClients.js';
15
+ import { Permission } from '../../src/permissions/index.js';
16
+
17
+ const buildPath = (p: string): string => `/openapi/v1/omadac1${p}`;
18
+ const MAC = '4C-1D-96-8D-37-C7';
19
+
20
+ function setup(knownClients: unknown[] = []) {
21
+ const request = {
22
+ post: vi.fn(),
23
+ request: vi.fn(),
24
+ fetchPaginated: vi.fn().mockResolvedValue(knownClients),
25
+ // Mirrors the real ensureSuccess: a typed error carrying the controller's errorCode.
26
+ ensureSuccess: vi.fn((response: { errorCode: number; msg?: string; result?: unknown }) => {
27
+ if (response.errorCode !== 0) throw new OmadaApiError(response.msg ?? 'Omada error', response.errorCode);
28
+ return response.result;
29
+ }),
30
+ };
31
+ const site = { resolveSiteId: vi.fn(() => 'site-1') } as unknown as SiteOperations;
32
+ return { request, ops: new ClientOperations(request as unknown as RequestHandler, site, buildPath) };
33
+ }
34
+
35
+ const doesNotExist = { errorCode: CLIENT_DOES_NOT_EXIST, msg: 'This client does not exist.' };
36
+
37
+ describe('normalizeMac', () => {
38
+ it('treats colon, dash and bare formats as the same MAC', () => {
39
+ expect(normalizeMac('4c:1d:96:8d:37:c7')).toBe(normalizeMac(MAC));
40
+ expect(normalizeMac('4c1d968d37c7')).toBe('4C1D968D37C7');
41
+ });
42
+ });
43
+
44
+ describe('ClientOperations.unblockClient', () => {
45
+ it('unblocks normally when the controller knows the client', async () => {
46
+ const { request, ops } = setup();
47
+ request.post.mockResolvedValue({ errorCode: 0 });
48
+
49
+ await expect(ops.unblockClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', blocked: false });
50
+ expect(request.fetchPaginated).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('reports success when the client is known and no longer blocked (e.g. unblocked in the web UI)', async () => {
54
+ const { request, ops } = setup([{ mac: '4c:1d:96:8d:37:c7', name: 'node-1271', block: false }]);
55
+ request.post.mockResolvedValue(doesNotExist);
56
+
57
+ await expect(ops.unblockClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', blocked: false });
58
+ expect(request.fetchPaginated).toHaveBeenCalledWith('/openapi/v1/omadac1/sites/site-1/insight/clients');
59
+ });
60
+
61
+ it('explains the controller limitation when the known client is still blocked', async () => {
62
+ const { request, ops } = setup([{ mac: MAC, name: 'node-1271', block: true }]);
63
+ request.post.mockResolvedValue(doesNotExist);
64
+
65
+ await expect(ops.unblockClient(MAC)).rejects.toThrow(/cannot unblock .* offline.*Known Clients.*omada_deleteClient/);
66
+ });
67
+
68
+ it('a known record without block state is not proof of an unblock', async () => {
69
+ const { request, ops } = setup([{ mac: MAC, name: 'node-1271' }]);
70
+ request.post.mockResolvedValue(doesNotExist);
71
+
72
+ await expect(ops.unblockClient(MAC)).rejects.toThrow(/cannot unblock/);
73
+ });
74
+
75
+ it('never reports success when the controller has no record at all (an orphaned block may remain)', async () => {
76
+ const { request, ops } = setup([{ mac: 'AA-BB-CC-DD-EE-FF', block: false }]);
77
+ request.post.mockResolvedValue(doesNotExist);
78
+
79
+ await expect(ops.unblockClient(MAC)).rejects.toThrow(/no record of .*orphaned block.*omada_deleteClient/);
80
+ });
81
+
82
+ it('other controller errors are passed through as before', async () => {
83
+ const { request, ops } = setup();
84
+ request.post.mockResolvedValue({ errorCode: -1, msg: 'General error' });
85
+
86
+ await expect(ops.unblockClient(MAC)).rejects.toThrow('General error');
87
+ expect(request.fetchPaginated).not.toHaveBeenCalled();
88
+ });
89
+ });
90
+
91
+ describe('unblockClient through the real request handler', () => {
92
+ afterEach(() => vi.unstubAllGlobals());
93
+
94
+ it('handles "client does not exist" sent with an HTTP error status, too', async () => {
95
+ const fetchMock = vi.fn(async (url: string) => {
96
+ if (url.includes('/unblock')) {
97
+ return new Response(JSON.stringify({ errorCode: CLIENT_DOES_NOT_EXIST, msg: 'This client does not exist.' }), { status: 400 });
98
+ }
99
+ // Known clients: present and no longer blocked.
100
+ return new Response(JSON.stringify({ errorCode: 0, result: { totalRows: 1, data: [{ mac: MAC, block: false }] } }), { status: 200 });
101
+ });
102
+ vi.stubGlobal('fetch', fetchMock);
103
+ const auth = { getAccessToken: vi.fn().mockResolvedValue('t'), clearToken: vi.fn() };
104
+ const request = new RequestHandler({ baseUrl: 'https://omada.test', strictSsl: true }, auth as never);
105
+ const site = { resolveSiteId: vi.fn(() => 'site-1') } as unknown as SiteOperations;
106
+ const ops = new ClientOperations(request, site, buildPath);
107
+
108
+ await expect(ops.unblockClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', blocked: false });
109
+ expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/insight/clients'))).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe('ClientOperations.listBlockedClients / deleteClient', () => {
114
+ it('lists only blocked known clients, offline ones included', async () => {
115
+ const { ops } = setup([
116
+ { mac: MAC, name: 'node-1271', block: true, lastSeen: 1 },
117
+ { mac: 'AA-BB-CC-DD-EE-FF', name: 'tv', block: false },
118
+ { mac: '11-22-33-44-55-66', name: 'old' },
119
+ ]);
120
+
121
+ await expect(ops.listBlockedClients()).resolves.toEqual([{ mac: MAC, name: 'node-1271', block: true, lastSeen: 1 }]);
122
+ });
123
+
124
+ it('deletes the client record with DELETE /clients/{mac}, encoding the MAC', async () => {
125
+ const { request, ops } = setup();
126
+ request.request.mockResolvedValue({ errorCode: 0 });
127
+
128
+ await expect(ops.deleteClient(MAC)).resolves.toEqual({ mac: MAC, siteId: 'site-1', deleted: true });
129
+ expect(request.request).toHaveBeenCalledWith({ method: 'DELETE', url: `/openapi/v1/omadac1/sites/site-1/clients/${MAC}` });
130
+ });
131
+
132
+ it('surfaces a failed delete', async () => {
133
+ const { request, ops } = setup();
134
+ request.request.mockResolvedValue(doesNotExist);
135
+
136
+ await expect(ops.deleteClient(MAC)).rejects.toThrow('This client does not exist.');
137
+ });
138
+ });
139
+
140
+ describe('client block tools: permissions', () => {
141
+ const extra = (permissions: number) => ({ sessionId: 's', http: { authInfo: { extra: { permissions } } } });
142
+
143
+ function register() {
144
+ const handlers = new Map<string, (a: unknown, e: unknown) => Promise<{ content: { text: string }[] }>>();
145
+ const server = { registerTool: vi.fn((name, _c, h) => handlers.set(name, h)) } as unknown as McpServer;
146
+ const client = { deleteClient: vi.fn().mockResolvedValue({ deleted: true }), listBlockedClients: vi.fn().mockResolvedValue([]) };
147
+ registerDeleteClientTool(server, client as unknown as OmadaClient);
148
+ registerListBlockedClientsTool(server, client as unknown as OmadaClient);
149
+ return { handlers, client };
150
+ }
151
+
152
+ let tools: ReturnType<typeof register>;
153
+ beforeEach(() => {
154
+ tools = register();
155
+ });
156
+
157
+ it('omada_deleteClient needs CONFIGURE (CONTROL is not enough)', async () => {
158
+ const denied = await tools.handlers.get('omada_deleteClient')!({ clientMac: MAC }, extra(Permission.QUERY | Permission.CONTROL));
159
+ expect(JSON.parse(denied.content[0].text)).toMatchObject({ error: 'Permission denied' });
160
+ expect(tools.client.deleteClient).not.toHaveBeenCalled();
161
+
162
+ await tools.handlers.get('omada_deleteClient')!({ clientMac: MAC }, extra(Permission.CONFIGURE));
163
+ expect(tools.client.deleteClient).toHaveBeenCalledWith(MAC, undefined);
164
+ });
165
+
166
+ it('omada_listBlockedClients needs only QUERY', async () => {
167
+ await tools.handlers.get('omada_listBlockedClients')!({}, extra(Permission.QUERY));
168
+ expect(tools.client.listBlockedClients).toHaveBeenCalled();
169
+ });
170
+ });
@@ -0,0 +1,499 @@
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
+ });