@nubisco/openbridge 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -0
  3. package/dist/__tests__/homekit-service-type.test.d.ts +2 -0
  4. package/dist/__tests__/homekit-service-type.test.d.ts.map +1 -0
  5. package/dist/__tests__/homekit-service-type.test.js +239 -0
  6. package/dist/__tests__/homekit-service-type.test.js.map +1 -0
  7. package/dist/__tests__/homekit-visibility.test.d.ts +2 -0
  8. package/dist/__tests__/homekit-visibility.test.d.ts.map +1 -0
  9. package/dist/__tests__/homekit-visibility.test.js +157 -0
  10. package/dist/__tests__/homekit-visibility.test.js.map +1 -0
  11. package/dist/__tests__/server.test.d.ts +2 -0
  12. package/dist/__tests__/server.test.d.ts.map +1 -0
  13. package/dist/__tests__/server.test.js +837 -0
  14. package/dist/__tests__/server.test.js.map +1 -0
  15. package/dist/__tests__/timeseries.test.d.ts +2 -0
  16. package/dist/__tests__/timeseries.test.d.ts.map +1 -0
  17. package/dist/__tests__/timeseries.test.js +216 -0
  18. package/dist/__tests__/timeseries.test.js.map +1 -0
  19. package/dist/auth.d.ts +23 -0
  20. package/dist/auth.d.ts.map +1 -0
  21. package/dist/auth.js +171 -0
  22. package/dist/auth.js.map +1 -0
  23. package/dist/daemon.d.ts +57 -0
  24. package/dist/daemon.d.ts.map +1 -0
  25. package/dist/daemon.js +636 -0
  26. package/dist/daemon.js.map +1 -0
  27. package/dist/homekit-service-type.d.ts +82 -0
  28. package/dist/homekit-service-type.d.ts.map +1 -0
  29. package/dist/homekit-service-type.js +184 -0
  30. package/dist/homekit-service-type.js.map +1 -0
  31. package/dist/homekit-visibility.d.ts +79 -0
  32. package/dist/homekit-visibility.d.ts.map +1 -0
  33. package/dist/homekit-visibility.js +164 -0
  34. package/dist/homekit-visibility.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +62 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/metrics.d.ts +17 -0
  40. package/dist/metrics.d.ts.map +1 -0
  41. package/dist/metrics.js +52 -0
  42. package/dist/metrics.js.map +1 -0
  43. package/dist/platform-client.d.ts +42 -0
  44. package/dist/platform-client.d.ts.map +1 -0
  45. package/dist/platform-client.js +92 -0
  46. package/dist/platform-client.js.map +1 -0
  47. package/dist/server.d.ts +30 -0
  48. package/dist/server.d.ts.map +1 -0
  49. package/dist/server.js +1744 -0
  50. package/dist/server.js.map +1 -0
  51. package/dist/timeseries.d.ts +134 -0
  52. package/dist/timeseries.d.ts.map +1 -0
  53. package/dist/timeseries.js +378 -0
  54. package/dist/timeseries.js.map +1 -0
  55. package/dist/version.d.ts +4 -0
  56. package/dist/version.d.ts.map +1 -0
  57. package/dist/version.js +28 -0
  58. package/dist/version.js.map +1 -0
  59. package/package.json +79 -0
  60. package/scripts/entrypoint.sh +82 -0
  61. package/scripts/fix-node-pty.mjs +22 -0
  62. package/ui-dist/android-chrome-192x192.png +0 -0
  63. package/ui-dist/android-chrome-512x512.png +0 -0
  64. package/ui-dist/apple-touch-icon.png +0 -0
  65. package/ui-dist/assets/index-Q0sxZHIR.css +32 -0
  66. package/ui-dist/assets/index-ZZYyAzBo.js +106 -0
  67. package/ui-dist/favicon-16x16.png +0 -0
  68. package/ui-dist/favicon-32x32.png +0 -0
  69. package/ui-dist/favicon.ico +0 -0
  70. package/ui-dist/fonts/MesloLGLDZNerdFontMono-Bold.woff2 +0 -0
  71. package/ui-dist/fonts/MesloLGLDZNerdFontMono-BoldItalic.woff2 +0 -0
  72. package/ui-dist/fonts/MesloLGLDZNerdFontMono-Italic.woff2 +0 -0
  73. package/ui-dist/fonts/MesloLGLDZNerdFontMono-Regular.woff2 +0 -0
  74. package/ui-dist/index.html +18 -0
  75. package/ui-dist/logo.svg +30 -0
  76. package/ui-dist/nubisco-logo.svg +120 -0
  77. package/ui-dist/site.webmanifest +19 -0
@@ -0,0 +1,837 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
2
+ import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ // Test config in a temp directory: mirrors ~/.openbridge structure
6
+ const TEST_HOME = join(tmpdir(), `openbridge-test-${Date.now()}`);
7
+ const TEST_DIR = join(TEST_HOME, '.openbridge');
8
+ const CONFIG_PATH = join(TEST_DIR, 'config.json');
9
+ const PLUGINS_DIR = join(TEST_DIR, 'plugins');
10
+ const HB_PLUGINS_DIR = join(PLUGINS_DIR, 'homebridge');
11
+ const OB_PLUGINS_DIR = join(PLUGINS_DIR, 'openbridge');
12
+ // The port written into the test config.json. Distinct from the port a test
13
+ // server actually binds: see listen() below.
14
+ const TEST_PORT = 19582;
15
+ // Set by listen() to whatever port the OS handed the current test server.
16
+ let BASE = '';
17
+ /**
18
+ * Start a test server on an ephemeral port and point BASE at it.
19
+ *
20
+ * Every server here used to bind one hardcoded port. When an instance was slow
21
+ * to release it the next listen() blocked until the test timed out, and the
22
+ * test after that reached the *previous* server, which no longer had this
23
+ * test's mocks installed, so it failed with a confusing wrong-status assertion
24
+ * rather than anything resembling the real cause. Letting the OS pick the port
25
+ * removes the contention entirely.
26
+ */
27
+ async function listen(server) {
28
+ await server.listen({ port: 0, host: '127.0.0.1' });
29
+ const address = server.server.address();
30
+ BASE = `http://localhost:${address.port}`;
31
+ }
32
+ function writeConfig(config) {
33
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
34
+ }
35
+ function readConfig() {
36
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
37
+ }
38
+ const baseConfig = {
39
+ bridge: {
40
+ name: 'Test Bridge',
41
+ port: TEST_PORT,
42
+ hapPort: 59826,
43
+ pincode: '111-22-333',
44
+ logLevel: 'error',
45
+ },
46
+ platforms: [],
47
+ plugins: [
48
+ {
49
+ name: 'test-native-plugin',
50
+ enabled: true,
51
+ config: { foo: 'bar' },
52
+ },
53
+ ],
54
+ disabledPlugins: [],
55
+ };
56
+ describe('OpenBridge Server API', () => {
57
+ beforeAll(() => {
58
+ // Create test directories
59
+ mkdirSync(TEST_DIR, { recursive: true });
60
+ mkdirSync(HB_PLUGINS_DIR, { recursive: true });
61
+ mkdirSync(OB_PLUGINS_DIR, { recursive: true });
62
+ // Set env vars before importing daemon
63
+ process.env.HOME = TEST_HOME;
64
+ process.env.OPENBRIDGE_VERSION = '0.0.0-test';
65
+ });
66
+ afterAll(() => {
67
+ try {
68
+ rmSync(TEST_HOME, { recursive: true, force: true });
69
+ }
70
+ catch {
71
+ /* ignore cleanup errors */
72
+ }
73
+ });
74
+ describe('Health & System', () => {
75
+ beforeEach(() => {
76
+ writeConfig(baseConfig);
77
+ });
78
+ it('GET /api/health returns status ok', async () => {
79
+ // Import server lazily to pick up env changes
80
+ const { createServer } = await import('../server.js');
81
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
82
+ const registry = new PluginRegistry();
83
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
84
+ await listen(server);
85
+ try {
86
+ const res = await fetch(`${BASE}/api/health`);
87
+ const data = await res.json();
88
+ expect(res.status).toBe(200);
89
+ expect(data.status).toBe('ok');
90
+ expect(data.version).toBe('0.0.0-test');
91
+ expect(data.timestamp).toBeDefined();
92
+ }
93
+ finally {
94
+ await server.close();
95
+ }
96
+ });
97
+ it('GET /api/qr returns null when no HAP bridge', async () => {
98
+ const { createServer } = await import('../server.js');
99
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
100
+ const registry = new PluginRegistry();
101
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
102
+ await listen(server);
103
+ try {
104
+ const res = await fetch(`${BASE}/api/qr`);
105
+ const data = await res.json();
106
+ expect(data.setupURI).toBeNull();
107
+ expect(data.pincode).toBeNull();
108
+ }
109
+ finally {
110
+ await server.close();
111
+ }
112
+ });
113
+ it('GET /api/qr returns pairing info when HAP bridge is set', async () => {
114
+ const { createServer } = await import('../server.js');
115
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
116
+ const registry = new PluginRegistry();
117
+ const hapInfo = { setupURI: 'X-HM://test', pincode: '111-22-333' };
118
+ const server = await createServer(registry, null, hapInfo, [], new Set(), new Map());
119
+ await listen(server);
120
+ try {
121
+ const res = await fetch(`${BASE}/api/qr`);
122
+ const data = await res.json();
123
+ expect(data.setupURI).toBe('X-HM://test');
124
+ expect(data.pincode).toBe('111-22-333');
125
+ }
126
+ finally {
127
+ await server.close();
128
+ }
129
+ });
130
+ });
131
+ describe('Plugin Management', () => {
132
+ it('GET /api/plugins returns registered plugins', async () => {
133
+ writeConfig(baseConfig);
134
+ const { createServer } = await import('../server.js');
135
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
136
+ const registry = new PluginRegistry();
137
+ // Register a test plugin
138
+ registry.register({
139
+ manifest: { name: 'test-plugin', version: '1.0.0', description: 'A test' },
140
+ });
141
+ registry.updateStatus('test-plugin', 'running');
142
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
143
+ await listen(server);
144
+ try {
145
+ const res = await fetch(`${BASE}/api/plugins`);
146
+ const data = await res.json();
147
+ expect(data.plugins).toHaveLength(1);
148
+ expect(data.plugins[0].manifest.name).toBe('test-plugin');
149
+ expect(data.plugins[0].status).toBe('running');
150
+ }
151
+ finally {
152
+ await server.close();
153
+ }
154
+ });
155
+ it('persists a Homebridge plugin by package name, not the platform name it registers under', async () => {
156
+ // The loader looks plugins up by npm package name. A compat plugin is
157
+ // registered under its *platform* name, so persisting the registry id
158
+ // wrote a key the loader never matched and the plugin started anyway.
159
+ writeConfig(baseConfig);
160
+ const { createServer } = await import('../server.js');
161
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
162
+ const registry = new PluginRegistry();
163
+ const instance = registry.register({ manifest: { name: 'ShellyDS9', version: '?.?.?' } }, 'ShellyDS9');
164
+ instance.source = 'homebridge';
165
+ instance.platformName = 'ShellyDS9';
166
+ instance.packageName = 'homebridge-shelly-ds9';
167
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
168
+ await listen(server);
169
+ try {
170
+ await fetch(`${BASE}/api/plugins/ShellyDS9/disabled`, {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({ disabled: true }),
174
+ });
175
+ const disabled = readConfig().disabledPlugins;
176
+ expect(disabled).toContain('homebridge-shelly-ds9');
177
+ expect(disabled).not.toContain('ShellyDS9');
178
+ }
179
+ finally {
180
+ await server.close();
181
+ }
182
+ });
183
+ it('clears a stale entry written under the old platform-name key when re-enabling', async () => {
184
+ writeConfig({ ...baseConfig, disabledPlugins: ['ShellyDS9'] });
185
+ const { createServer } = await import('../server.js');
186
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
187
+ const registry = new PluginRegistry();
188
+ const instance = registry.register({ manifest: { name: 'ShellyDS9', version: '?.?.?' } }, 'ShellyDS9');
189
+ instance.source = 'homebridge';
190
+ instance.platformName = 'ShellyDS9';
191
+ instance.packageName = 'homebridge-shelly-ds9';
192
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
193
+ await listen(server);
194
+ try {
195
+ await fetch(`${BASE}/api/plugins/ShellyDS9/disabled`, {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({ disabled: false }),
199
+ });
200
+ // Both aliases must go, or the plugin stays off despite the toggle.
201
+ expect(readConfig().disabledPlugins).toEqual([]);
202
+ }
203
+ finally {
204
+ await server.close();
205
+ }
206
+ });
207
+ it('POST /api/plugins/:id/disabled toggles disabled state', async () => {
208
+ writeConfig(baseConfig);
209
+ const { createServer } = await import('../server.js');
210
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
211
+ const registry = new PluginRegistry();
212
+ registry.register({
213
+ manifest: { name: 'toggle-test', version: '1.0.0' },
214
+ });
215
+ registry.updateStatus('toggle-test', 'running');
216
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
217
+ await listen(server);
218
+ try {
219
+ // Disable
220
+ const res1 = await fetch(`${BASE}/api/plugins/toggle-test/disabled`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/json' },
223
+ body: JSON.stringify({ disabled: true }),
224
+ });
225
+ const data1 = await res1.json();
226
+ expect(data1.disabled).toBe(true);
227
+ // Verify persisted to config
228
+ const cfg = readConfig();
229
+ expect(cfg.disabledPlugins).toContain('toggle-test');
230
+ // Re-enable
231
+ const res2 = await fetch(`${BASE}/api/plugins/toggle-test/disabled`, {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({ disabled: false }),
235
+ });
236
+ const data2 = await res2.json();
237
+ expect(data2.disabled).toBe(false);
238
+ const cfg2 = readConfig();
239
+ expect(cfg2.disabledPlugins).not.toContain('toggle-test');
240
+ }
241
+ finally {
242
+ await server.close();
243
+ }
244
+ });
245
+ });
246
+ describe('Config Management', () => {
247
+ it('GET /api/config returns config content', async () => {
248
+ writeConfig(baseConfig);
249
+ const { createServer } = await import('../server.js');
250
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
251
+ const registry = new PluginRegistry();
252
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
253
+ await listen(server);
254
+ try {
255
+ const res = await fetch(`${BASE}/api/config`);
256
+ const data = await res.json();
257
+ expect(data.content).toBeDefined();
258
+ const parsed = JSON.parse(data.content);
259
+ expect(parsed.bridge.name).toBe('Test Bridge');
260
+ }
261
+ finally {
262
+ await server.close();
263
+ }
264
+ });
265
+ it('POST /api/config saves valid JSON', async () => {
266
+ writeConfig(baseConfig);
267
+ const { createServer } = await import('../server.js');
268
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
269
+ const registry = new PluginRegistry();
270
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
271
+ await listen(server);
272
+ try {
273
+ const newConfig = { ...baseConfig, bridge: { ...baseConfig.bridge, name: 'Updated Bridge' } };
274
+ const res = await fetch(`${BASE}/api/config`, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ content: JSON.stringify(newConfig, null, 2) }),
278
+ });
279
+ expect(res.ok).toBe(true);
280
+ // Verify file was written
281
+ const saved = readConfig();
282
+ expect(saved.bridge.name).toBe('Updated Bridge');
283
+ }
284
+ finally {
285
+ await server.close();
286
+ }
287
+ });
288
+ it('POST /api/config rejects invalid JSON', async () => {
289
+ writeConfig(baseConfig);
290
+ const { createServer } = await import('../server.js');
291
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
292
+ const registry = new PluginRegistry();
293
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
294
+ await listen(server);
295
+ try {
296
+ const res = await fetch(`${BASE}/api/config`, {
297
+ method: 'POST',
298
+ headers: { 'Content-Type': 'application/json' },
299
+ body: JSON.stringify({ content: 'not valid json {{' }),
300
+ });
301
+ expect(res.ok).toBe(false);
302
+ }
303
+ finally {
304
+ await server.close();
305
+ }
306
+ });
307
+ });
308
+ describe('Bridge Config', () => {
309
+ it('GET /api/bridge returns bridge settings', async () => {
310
+ writeConfig(baseConfig);
311
+ const { createServer } = await import('../server.js');
312
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
313
+ const registry = new PluginRegistry();
314
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
315
+ await listen(server);
316
+ try {
317
+ const res = await fetch(`${BASE}/api/bridge`);
318
+ const data = await res.json();
319
+ expect(data.name).toBe('Test Bridge');
320
+ expect(data.port).toBe(TEST_PORT);
321
+ expect(data.pincode).toBe('111-22-333');
322
+ }
323
+ finally {
324
+ await server.close();
325
+ }
326
+ });
327
+ it('POST /api/bridge updates bridge config', async () => {
328
+ writeConfig(baseConfig);
329
+ const { createServer } = await import('../server.js');
330
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
331
+ const registry = new PluginRegistry();
332
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
333
+ await listen(server);
334
+ try {
335
+ const res = await fetch(`${BASE}/api/bridge`, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ name: 'New Bridge Name' }),
339
+ });
340
+ expect(res.ok).toBe(true);
341
+ const saved = readConfig();
342
+ expect(saved.bridge.name).toBe('New Bridge Name');
343
+ }
344
+ finally {
345
+ await server.close();
346
+ }
347
+ });
348
+ });
349
+ describe('Device Management', () => {
350
+ it('GET /api/devices returns devices from registry', async () => {
351
+ writeConfig(baseConfig);
352
+ const { createServer } = await import('../server.js');
353
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
354
+ const registry = new PluginRegistry();
355
+ // Register a plugin with devices
356
+ const instance = registry.register({
357
+ manifest: { name: 'device-plugin', version: '1.0.0' },
358
+ });
359
+ instance.devices = {
360
+ 'dev-1': {
361
+ id: 'dev-1',
362
+ name: 'Test Switch',
363
+ widgetType: 'switch',
364
+ pluginId: 'device-plugin',
365
+ },
366
+ };
367
+ instance.telemetry = {
368
+ 'dev-1': { active: true, _updatedAt: new Date().toISOString() },
369
+ };
370
+ registry.updateStatus('device-plugin', 'running');
371
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
372
+ await listen(server);
373
+ try {
374
+ const res = await fetch(`${BASE}/api/devices`);
375
+ const data = await res.json();
376
+ expect(data.devices).toHaveLength(1);
377
+ expect(data.devices[0].id).toBe('dev-1');
378
+ expect(data.devices[0].name).toBe('Test Switch');
379
+ expect(data.devices[0].telemetry.active).toBe(true);
380
+ expect(data.devices[0].pluginStatus).toBe('running');
381
+ }
382
+ finally {
383
+ await server.close();
384
+ }
385
+ });
386
+ it('POST /api/devices/:id/control calls registered handler', async () => {
387
+ writeConfig(baseConfig);
388
+ const { createServer } = await import('../server.js');
389
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
390
+ const registry = new PluginRegistry();
391
+ let controlledValue = null;
392
+ const controls = new Map();
393
+ controls.set('dev-1::active', (value) => {
394
+ controlledValue = value;
395
+ });
396
+ // Register device
397
+ const instance = registry.register({
398
+ manifest: { name: 'ctrl-plugin', version: '1.0.0' },
399
+ });
400
+ instance.devices = {
401
+ 'dev-1': { id: 'dev-1', name: 'Switch', widgetType: 'switch', pluginId: 'ctrl-plugin' },
402
+ };
403
+ registry.updateStatus('ctrl-plugin', 'running');
404
+ const server = await createServer(registry, null, null, [], new Set(), controls);
405
+ await listen(server);
406
+ try {
407
+ const res = await fetch(`${BASE}/api/devices/dev-1/control`, {
408
+ method: 'POST',
409
+ headers: { 'Content-Type': 'application/json' },
410
+ body: JSON.stringify({ control: 'active', value: false }),
411
+ });
412
+ expect(res.ok).toBe(true);
413
+ expect(controlledValue).toBe(false);
414
+ }
415
+ finally {
416
+ await server.close();
417
+ }
418
+ });
419
+ });
420
+ describe('Marketplace Uninstall', () => {
421
+ it('DELETE /api/marketplace/uninstall prunes config.plugins', async () => {
422
+ const config = {
423
+ ...baseConfig,
424
+ plugins: [
425
+ { name: 'keep-this', config: {} },
426
+ { name: 'remove-this', config: {} },
427
+ ],
428
+ };
429
+ writeConfig(config);
430
+ const { createServer } = await import('../server.js');
431
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
432
+ const registry = new PluginRegistry();
433
+ // Register the plugin to be removed
434
+ registry.register({
435
+ manifest: { name: 'remove-this', version: '1.0.0' },
436
+ });
437
+ // Create a proper npm package structure so npm uninstall works
438
+ writeFileSync(join(HB_PLUGINS_DIR, 'package.json'), JSON.stringify({ dependencies: { 'remove-this': '1.0.0' } }));
439
+ const pkgDir = join(HB_PLUGINS_DIR, 'node_modules', 'remove-this');
440
+ mkdirSync(pkgDir, { recursive: true });
441
+ writeFileSync(join(pkgDir, 'package.json'), '{"name":"remove-this","version":"1.0.0"}');
442
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
443
+ await listen(server);
444
+ try {
445
+ const res = await fetch(`${BASE}/api/marketplace/uninstall/remove-this`, {
446
+ method: 'DELETE',
447
+ });
448
+ const data = await res.json();
449
+ expect(data.uninstalled).toBe('remove-this');
450
+ // Verify plugin removed from config
451
+ const saved = readConfig();
452
+ const plugins = saved.plugins;
453
+ expect(plugins).toHaveLength(1);
454
+ expect(plugins[0].name).toBe('keep-this');
455
+ // Verify removed from registry
456
+ expect(registry.get('remove-this')).toBeUndefined();
457
+ }
458
+ finally {
459
+ await server.close();
460
+ }
461
+ });
462
+ it('DELETE /api/marketplace/uninstall prunes config.platforms', async () => {
463
+ const config = {
464
+ ...baseConfig,
465
+ platforms: [
466
+ { platform: 'KeepPlatform', plugin: `${HB_PLUGINS_DIR}/node_modules/keep-plugin/dist/index.js` },
467
+ { platform: 'RemovePlatform', plugin: `${HB_PLUGINS_DIR}/node_modules/remove-plugin/dist/index.js` },
468
+ ],
469
+ };
470
+ writeConfig(config);
471
+ const { createServer } = await import('../server.js');
472
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
473
+ const registry = new PluginRegistry();
474
+ // Create proper npm package structure
475
+ writeFileSync(join(HB_PLUGINS_DIR, 'package.json'), JSON.stringify({ dependencies: { 'remove-plugin': '1.0.0' } }));
476
+ const pkgDir = join(HB_PLUGINS_DIR, 'node_modules', 'remove-plugin');
477
+ mkdirSync(pkgDir, { recursive: true });
478
+ writeFileSync(join(pkgDir, 'package.json'), '{"name":"remove-plugin","version":"1.0.0"}');
479
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
480
+ await listen(server);
481
+ try {
482
+ const res = await fetch(`${BASE}/api/marketplace/uninstall/remove-plugin`, {
483
+ method: 'DELETE',
484
+ });
485
+ expect(res.ok).toBe(true);
486
+ const saved = readConfig();
487
+ const platforms = saved.platforms;
488
+ expect(platforms).toHaveLength(1);
489
+ expect(platforms[0].platform).toBe('KeepPlatform');
490
+ }
491
+ finally {
492
+ await server.close();
493
+ }
494
+ });
495
+ });
496
+ describe('Platform Auth Callback (multi-account rules)', () => {
497
+ const ISSUER = 'https://platform.test';
498
+ const KID = 'test-key';
499
+ let keyPair;
500
+ let publicJwk;
501
+ beforeAll(async () => {
502
+ keyPair = (await crypto.subtle.generateKey({ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify']));
503
+ publicJwk = { ...(await crypto.subtle.exportKey('jwk', keyPair.publicKey)), kid: KID, alg: 'RS256', use: 'sig' };
504
+ });
505
+ async function signJwt(claims) {
506
+ const enc = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
507
+ const signingInput = `${enc({ alg: 'RS256', kid: KID })}.${enc(claims)}`;
508
+ const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, Buffer.from(signingInput));
509
+ return `${signingInput}.${Buffer.from(sig).toString('base64url')}`;
510
+ }
511
+ function baseClaims() {
512
+ const now = Math.floor(Date.now() / 1000);
513
+ return {
514
+ sub: 'u-jose',
515
+ email: 'jose@nubisco.io',
516
+ role: 'admin',
517
+ app: 'openbridge',
518
+ iss: ISSUER,
519
+ iat: now,
520
+ exp: now + 300,
521
+ };
522
+ }
523
+ async function withAuthServer(fn) {
524
+ writeConfig(baseConfig);
525
+ process.env.PLATFORM_ENABLED = 'true';
526
+ process.env.PLATFORM_ISSUER = ISSUER;
527
+ process.env.PLATFORM_APP_ID = 'openbridge';
528
+ const realFetch = globalThis.fetch;
529
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
530
+ if (String(input) === `${ISSUER}/.well-known/jwks.json`) {
531
+ return Promise.resolve(new Response(JSON.stringify({ keys: [publicJwk] }), { status: 200 }));
532
+ }
533
+ return realFetch(input, init);
534
+ });
535
+ const { createServer } = await import('../server.js');
536
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
537
+ const server = await createServer(new PluginRegistry(), null, null, [], new Set(), new Map());
538
+ await listen(server);
539
+ try {
540
+ await fn(BASE);
541
+ }
542
+ finally {
543
+ fetchSpy.mockRestore();
544
+ delete process.env.PLATFORM_ENABLED;
545
+ delete process.env.PLATFORM_ISSUER;
546
+ delete process.env.PLATFORM_APP_ID;
547
+ await server.close();
548
+ }
549
+ }
550
+ async function postCallback(base, body) {
551
+ return fetch(`${base}/auth/platform/callback`, {
552
+ method: 'POST',
553
+ headers: { 'Content-Type': 'application/json' },
554
+ body: JSON.stringify(body),
555
+ });
556
+ }
557
+ it('accepts a valid token for this app and sets a session cookie', async () => {
558
+ await withAuthServer(async (base) => {
559
+ const res = await postCallback(base, { token: await signJwt(baseClaims()) });
560
+ expect(res.status).toBe(200);
561
+ expect(res.headers.get('set-cookie')).toContain('openbridge_session=');
562
+ const data = await res.json();
563
+ expect(data.user).toEqual({ id: 'u-jose', email: 'jose@nubisco.io' });
564
+ });
565
+ });
566
+ it('rejects a token issued to a different app', async () => {
567
+ await withAuthServer(async (base) => {
568
+ const res = await postCallback(base, { token: await signJwt({ ...baseClaims(), app: 'analytics' }) });
569
+ expect(res.status).toBe(401);
570
+ expect((await res.json()).error).toBe('wrong_app');
571
+ });
572
+ });
573
+ it('rejects a renewal whose subject changed (expected_sub mismatch)', async () => {
574
+ await withAuthServer(async (base) => {
575
+ const res = await postCallback(base, { token: await signJwt(baseClaims()), expected_sub: 'u-tools' });
576
+ expect(res.status).toBe(409);
577
+ expect((await res.json()).error).toBe('subject_mismatch');
578
+ });
579
+ });
580
+ it('accepts a renewal when the subject matches expected_sub', async () => {
581
+ await withAuthServer(async (base) => {
582
+ const res = await postCallback(base, { token: await signJwt(baseClaims()), expected_sub: 'u-jose' });
583
+ expect(res.status).toBe(200);
584
+ });
585
+ });
586
+ });
587
+ describe('Update Check', () => {
588
+ beforeEach(() => {
589
+ writeConfig(baseConfig);
590
+ });
591
+ it('GET /api/updates/check resolves latest via releases redirect even when the API is rate-limited', async () => {
592
+ const { createServer } = await import('../server.js');
593
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
594
+ const registry = new PluginRegistry();
595
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
596
+ await listen(server);
597
+ const realFetch = globalThis.fetch;
598
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
599
+ const url = String(input);
600
+ if (url === 'https://github.com/nubisco/openbridge/releases/latest') {
601
+ return Promise.resolve(new Response(null, {
602
+ status: 302,
603
+ headers: { location: 'https://github.com/nubisco/openbridge/releases/tag/v9.9.9' },
604
+ }));
605
+ }
606
+ if (url.startsWith('https://api.github.com/')) {
607
+ return Promise.resolve(new Response('{"message":"API rate limit exceeded"}', { status: 403 }));
608
+ }
609
+ return realFetch(input, init);
610
+ });
611
+ try {
612
+ const res = await fetch(`${BASE}/api/updates/check`);
613
+ const data = await res.json();
614
+ expect(res.status).toBe(200);
615
+ expect(data.current).toBe('0.0.0-test');
616
+ expect(data.latest).toBe('9.9.9');
617
+ expect(data.updateAvailable).toBe(true);
618
+ expect(data.releaseUrl).toBe('https://github.com/nubisco/openbridge/releases/tag/v9.9.9');
619
+ expect(data.releaseNotes).toBeNull();
620
+ }
621
+ finally {
622
+ fetchSpy.mockRestore();
623
+ await server.close();
624
+ }
625
+ });
626
+ it('GET /api/updates/check reports latest null when GitHub is unreachable', async () => {
627
+ const { createServer } = await import('../server.js');
628
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
629
+ const registry = new PluginRegistry();
630
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
631
+ await listen(server);
632
+ const realFetch = globalThis.fetch;
633
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
634
+ const url = String(input);
635
+ if (url.includes('github.com')) {
636
+ return Promise.reject(new TypeError('fetch failed'));
637
+ }
638
+ return realFetch(input, init);
639
+ });
640
+ try {
641
+ const res = await fetch(`${BASE}/api/updates/check`);
642
+ const data = await res.json();
643
+ expect(res.status).toBe(200);
644
+ expect(data.current).toBe('0.0.0-test');
645
+ expect(data.latest).toBeNull();
646
+ expect(data.updateAvailable).toBe(false);
647
+ }
648
+ finally {
649
+ fetchSpy.mockRestore();
650
+ await server.close();
651
+ }
652
+ });
653
+ });
654
+ describe('Device Metrics', () => {
655
+ beforeEach(() => {
656
+ writeConfig(baseConfig);
657
+ });
658
+ async function serverWithMetricDevice() {
659
+ const { createServer } = await import('../server.js');
660
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
661
+ const registry = new PluginRegistry();
662
+ registry.register({
663
+ manifest: { name: 'shelly-test', version: '1.0.0' },
664
+ });
665
+ const entry = registry.get('shelly-test');
666
+ entry.instance.devices = {
667
+ 'shelly-abc-p1': {
668
+ id: 'shelly-abc-p1',
669
+ name: 'Home - Phase B',
670
+ widgetType: 'energy_meter',
671
+ pluginId: 'shelly-test',
672
+ metrics: [
673
+ { key: 'power', label: 'Power', unit: 'W', kind: 'instant' },
674
+ { key: 'totalForwardEnergy', label: 'Energy', unit: 'kWh', kind: 'cumulative' },
675
+ ],
676
+ },
677
+ plain: { id: 'plain', name: 'No Metrics', widgetType: 'switch', pluginId: 'shelly-test' },
678
+ };
679
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
680
+ await listen(server);
681
+ return server;
682
+ }
683
+ it('lists a device metrics when no metric is requested', async () => {
684
+ const server = await serverWithMetricDevice();
685
+ try {
686
+ const res = await fetch(`${BASE}/api/devices/shelly-abc-p1/metrics`);
687
+ const data = await res.json();
688
+ expect(res.status).toBe(200);
689
+ expect(data.metrics.map((m) => m.key)).toEqual(['power', 'totalForwardEnergy']);
690
+ // No series until a metric is named, so the UI can build its selector
691
+ // without paying for a range read.
692
+ expect(data.series).toBeNull();
693
+ }
694
+ finally {
695
+ await server.close();
696
+ }
697
+ });
698
+ it('returns an empty metric list for a device that declares none', async () => {
699
+ const server = await serverWithMetricDevice();
700
+ try {
701
+ const res = await fetch(`${BASE}/api/devices/plain/metrics`);
702
+ const data = await res.json();
703
+ expect(res.status).toBe(200);
704
+ expect(data.metrics).toEqual([]);
705
+ }
706
+ finally {
707
+ await server.close();
708
+ }
709
+ });
710
+ it('404s for an unknown device', async () => {
711
+ const server = await serverWithMetricDevice();
712
+ try {
713
+ const res = await fetch(`${BASE}/api/devices/nope/metrics`);
714
+ expect(res.status).toBe(404);
715
+ }
716
+ finally {
717
+ await server.close();
718
+ }
719
+ });
720
+ it('404s for a metric the device does not report', async () => {
721
+ const server = await serverWithMetricDevice();
722
+ try {
723
+ const res = await fetch(`${BASE}/api/devices/shelly-abc-p1/metrics?metric=humidity`);
724
+ expect(res.status).toBe(404);
725
+ }
726
+ finally {
727
+ await server.close();
728
+ }
729
+ });
730
+ it('rejects an inverted range rather than returning nonsense', async () => {
731
+ const server = await serverWithMetricDevice();
732
+ try {
733
+ const res = await fetch(`${BASE}/api/devices/shelly-abc-p1/metrics?metric=power&from=2000&to=1000`);
734
+ expect(res.status).toBe(400);
735
+ }
736
+ finally {
737
+ await server.close();
738
+ }
739
+ });
740
+ it('returns an empty series with resolution metadata when nothing is recorded yet', async () => {
741
+ const server = await serverWithMetricDevice();
742
+ try {
743
+ const res = await fetch(`${BASE}/api/devices/shelly-abc-p1/metrics?metric=power&from=1000&to=2000`);
744
+ const data = await res.json();
745
+ expect(res.status).toBe(200);
746
+ expect(data.series.metric.unit).toBe('W');
747
+ expect(data.series.points).toEqual([]);
748
+ // The UI needs the resolution to label the axis even with no data.
749
+ expect(data.series.resolution).toBeGreaterThan(0);
750
+ }
751
+ finally {
752
+ await server.close();
753
+ }
754
+ });
755
+ });
756
+ describe('Marketplace Search', () => {
757
+ beforeEach(() => {
758
+ writeConfig(baseConfig);
759
+ });
760
+ it('GET /api/marketplace/search returns native plugins alongside Homebridge ones', async () => {
761
+ const { createServer } = await import('../server.js');
762
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
763
+ const registry = new PluginRegistry();
764
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
765
+ await listen(server);
766
+ // npm has no OR for keywords, so the route must issue one query per
767
+ // keyword. Each mocked response answers only its own keyword, exactly as
768
+ // the real registry does.
769
+ const realFetch = globalThis.fetch;
770
+ const queried = [];
771
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
772
+ const url = String(input);
773
+ if (url.startsWith('https://registry.npmjs.org/-/v1/search')) {
774
+ const isNative = url.includes('openbridge-plugin');
775
+ queried.push(isNative ? 'openbridge-plugin' : 'homebridge-plugin');
776
+ const body = isNative
777
+ ? {
778
+ objects: [{ package: { name: '@nubisco/openbridge-shelly-platform' }, searchScore: 0.9 }],
779
+ total: 2,
780
+ time: 'now',
781
+ }
782
+ : {
783
+ objects: [
784
+ { package: { name: 'homebridge-shelly-ds9' }, searchScore: 0.5 },
785
+ // Also returned by the native query: must not appear twice.
786
+ { package: { name: '@nubisco/openbridge-shelly-platform' }, searchScore: 0.9 },
787
+ ],
788
+ total: 5651,
789
+ time: 'now',
790
+ };
791
+ return Promise.resolve(new Response(JSON.stringify(body), { status: 200 }));
792
+ }
793
+ return realFetch(input, init);
794
+ });
795
+ try {
796
+ const res = await fetch(`${BASE}/api/marketplace/search?q=shelly`);
797
+ const data = await res.json();
798
+ expect(res.status).toBe(200);
799
+ expect(queried.sort()).toEqual(['homebridge-plugin', 'openbridge-plugin']);
800
+ const names = data.objects.map((o) => o.package.name);
801
+ expect(names).toContain('@nubisco/openbridge-shelly-platform');
802
+ expect(names).toContain('homebridge-shelly-ds9');
803
+ // Deduped by package name, and ordered by npm's own relevance score.
804
+ expect(names.filter((n) => n === '@nubisco/openbridge-shelly-platform')).toHaveLength(1);
805
+ expect(names[0]).toBe('@nubisco/openbridge-shelly-platform');
806
+ }
807
+ finally {
808
+ fetchSpy.mockRestore();
809
+ await server.close();
810
+ }
811
+ });
812
+ it('GET /api/marketplace/search surfaces registry failures', async () => {
813
+ const { createServer } = await import('../server.js');
814
+ const { PluginRegistry } = await import('@nubisco/openbridge-core');
815
+ const registry = new PluginRegistry();
816
+ const server = await createServer(registry, null, null, [], new Set(), new Map());
817
+ await listen(server);
818
+ const realFetch = globalThis.fetch;
819
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
820
+ const url = String(input);
821
+ if (url.startsWith('https://registry.npmjs.org/-/v1/search')) {
822
+ return Promise.resolve(new Response('upstream boom', { status: 500 }));
823
+ }
824
+ return realFetch(input, init);
825
+ });
826
+ try {
827
+ const res = await fetch(`${BASE}/api/marketplace/search?q=shelly`);
828
+ expect(res.status).toBe(502);
829
+ }
830
+ finally {
831
+ fetchSpy.mockRestore();
832
+ await server.close();
833
+ }
834
+ });
835
+ });
836
+ });
837
+ //# sourceMappingURL=server.test.js.map