claw-dashboard 1.8.4 → 2.0.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.
- package/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5331 -512
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget Integration Tests
|
|
3
|
+
* Tests for plugin loading, manifest validation, and config sanitization
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { jest } from '@jest/globals';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
import { tmpdir } from 'os';
|
|
11
|
+
|
|
12
|
+
// Import modules under test
|
|
13
|
+
import { WidgetLoader } from '../src/widgets/widget-loader.js';
|
|
14
|
+
import { validateManifest, BaseWidget, PluginAPI } from '../src/widgets/plugin-api.js';
|
|
15
|
+
import { sanitizeWidgetConfig, validateWidgetConfig, WidgetConfigValidator, validatePluginPath, validatePluginName } from '../src/security.js';
|
|
16
|
+
|
|
17
|
+
describe('WidgetLoader', () => {
|
|
18
|
+
let loader;
|
|
19
|
+
let tempDir;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
// Create temp directory for test plugins
|
|
23
|
+
tempDir = await mkdtemp(join(tmpdir(), 'widget-test-'));
|
|
24
|
+
loader = new WidgetLoader({ pluginsDir: tempDir });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
// Cleanup
|
|
29
|
+
if (loader) {
|
|
30
|
+
await loader.clear();
|
|
31
|
+
}
|
|
32
|
+
if (tempDir) {
|
|
33
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('register() and load()', () => {
|
|
38
|
+
test('should register a widget without loading it', () => {
|
|
39
|
+
const loader = new WidgetLoader();
|
|
40
|
+
const metadata = { name: 'Test Widget', version: '1.0.0' };
|
|
41
|
+
const loaderFn = jest.fn().mockResolvedValue({ render: jest.fn(), getData: jest.fn() });
|
|
42
|
+
|
|
43
|
+
loader.register('test-widget', metadata, loaderFn);
|
|
44
|
+
|
|
45
|
+
const meta = loader.getMetadata('test-widget');
|
|
46
|
+
expect(meta).toBeDefined();
|
|
47
|
+
expect(meta.name).toBe('Test Widget');
|
|
48
|
+
expect(loaderFn).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('should load a registered widget', async () => {
|
|
52
|
+
const loader = new WidgetLoader();
|
|
53
|
+
const instance = {
|
|
54
|
+
render: jest.fn(),
|
|
55
|
+
getData: jest.fn(),
|
|
56
|
+
destroy: jest.fn(),
|
|
57
|
+
};
|
|
58
|
+
const loaderFn = jest.fn().mockResolvedValue(instance);
|
|
59
|
+
|
|
60
|
+
loader.register('test-widget', { name: 'Test' }, loaderFn);
|
|
61
|
+
const loaded = await loader.load('test-widget');
|
|
62
|
+
|
|
63
|
+
expect(loaderFn).toHaveBeenCalled();
|
|
64
|
+
expect(loaded).toBe(instance);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should cache loaded widgets', async () => {
|
|
68
|
+
const loader = new WidgetLoader();
|
|
69
|
+
const loaderFn = jest.fn().mockResolvedValue({
|
|
70
|
+
render: jest.fn(),
|
|
71
|
+
getData: jest.fn(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
loader.register('test-widget', { name: 'Test' }, loaderFn);
|
|
75
|
+
|
|
76
|
+
await loader.load('test-widget');
|
|
77
|
+
await loader.load('test-widget');
|
|
78
|
+
|
|
79
|
+
expect(loaderFn).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('should throw for unregistered widget', async () => {
|
|
83
|
+
const loader = new WidgetLoader();
|
|
84
|
+
|
|
85
|
+
await expect(loader.load('nonexistent')).rejects.toThrow("Widget 'nonexistent' not registered");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('should loadAndRegister in one call', async () => {
|
|
89
|
+
const loader = new WidgetLoader();
|
|
90
|
+
const instance = {
|
|
91
|
+
render: jest.fn(),
|
|
92
|
+
getData: jest.fn(),
|
|
93
|
+
destroy: jest.fn(),
|
|
94
|
+
};
|
|
95
|
+
const loaderFn = jest.fn().mockResolvedValue(instance);
|
|
96
|
+
|
|
97
|
+
const loaded = await loader.loadAndRegister('test-widget', { name: 'Test' }, loaderFn);
|
|
98
|
+
|
|
99
|
+
expect(loaderFn).toHaveBeenCalled();
|
|
100
|
+
expect(loaded).toBe(instance);
|
|
101
|
+
|
|
102
|
+
// Verify it's registered
|
|
103
|
+
const meta = loader.getMetadata('test-widget');
|
|
104
|
+
expect(meta).toBeDefined();
|
|
105
|
+
expect(meta.name).toBe('Test');
|
|
106
|
+
|
|
107
|
+
// Verify it's loaded
|
|
108
|
+
expect(loader.isLoaded('test-widget')).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('should loadAndRegister with lazyLoad option', async () => {
|
|
112
|
+
const loader = new WidgetLoader();
|
|
113
|
+
const instance = {
|
|
114
|
+
render: jest.fn(),
|
|
115
|
+
getData: jest.fn(),
|
|
116
|
+
};
|
|
117
|
+
const loaderFn = jest.fn().mockResolvedValue(instance);
|
|
118
|
+
|
|
119
|
+
// Register with lazyLoad: true (default)
|
|
120
|
+
await loader.loadAndRegister('lazy-widget', { name: 'Lazy', lazyLoad: true }, loaderFn);
|
|
121
|
+
|
|
122
|
+
// Should be registered
|
|
123
|
+
expect(loader.getMetadata('lazy-widget')).toBeDefined();
|
|
124
|
+
|
|
125
|
+
// With lazyLoad, the loader shouldn't be called immediately
|
|
126
|
+
// (Note: loadAndRegister always loads, regardless of lazyLoad setting)
|
|
127
|
+
expect(loader.isLoaded('lazy-widget')).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('should handle loadAndRegister failure', async () => {
|
|
131
|
+
const loader = new WidgetLoader();
|
|
132
|
+
const loaderFn = jest.fn().mockRejectedValue(new Error('Load failed'));
|
|
133
|
+
|
|
134
|
+
await expect(loader.loadAndRegister('failing-widget', { name: 'Failing' }, loaderFn)).rejects.toThrow('Load failed');
|
|
135
|
+
|
|
136
|
+
// Widget should be registered even if load failed
|
|
137
|
+
expect(loader.getMetadata('failing-widget')).toBeDefined();
|
|
138
|
+
expect(loader.isLoaded('failing-widget')).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('should validate widget has required methods', async () => {
|
|
142
|
+
const loader = new WidgetLoader();
|
|
143
|
+
const invalidWidget = { someMethod: jest.fn() };
|
|
144
|
+
|
|
145
|
+
loader.register('invalid-widget', {}, () => Promise.resolve(invalidWidget));
|
|
146
|
+
|
|
147
|
+
await expect(loader.load('invalid-widget')).rejects.toThrow(/missing required methods/);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('loadMany()', () => {
|
|
152
|
+
test('should load multiple widgets in parallel', async () => {
|
|
153
|
+
const loader = new WidgetLoader();
|
|
154
|
+
const instances = {};
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < 3; i++) {
|
|
157
|
+
instances[`widget-${i}`] = {
|
|
158
|
+
render: jest.fn(),
|
|
159
|
+
getData: jest.fn(),
|
|
160
|
+
};
|
|
161
|
+
loader.register(`widget-${i}`, { name: `Widget ${i}` }, () => Promise.resolve(instances[`widget-${i}`]));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const loaded = await loader.loadMany(['widget-0', 'widget-1', 'widget-2']);
|
|
165
|
+
|
|
166
|
+
expect(loaded.size).toBe(3);
|
|
167
|
+
expect(loaded.get('widget-0')).toBe(instances['widget-0']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('should handle partial failures', async () => {
|
|
171
|
+
const loader = new WidgetLoader();
|
|
172
|
+
|
|
173
|
+
loader.register('good-widget', {}, () => Promise.resolve({
|
|
174
|
+
render: jest.fn(),
|
|
175
|
+
getData: jest.fn(),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
loader.register('bad-widget', {}, () => Promise.resolve({ invalid: true }));
|
|
179
|
+
|
|
180
|
+
const loaded = await loader.loadMany(['good-widget', 'bad-widget']);
|
|
181
|
+
|
|
182
|
+
// Good widget should load, bad widget should fail validation
|
|
183
|
+
expect(loaded.size).toBe(1);
|
|
184
|
+
expect(loaded.has('good-widget')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('hooks', () => {
|
|
189
|
+
test('should call beforeLoad and afterLoad hooks', async () => {
|
|
190
|
+
const loader = new WidgetLoader();
|
|
191
|
+
const beforeLoad = jest.fn();
|
|
192
|
+
const afterLoad = jest.fn();
|
|
193
|
+
|
|
194
|
+
loader.addHook('beforeLoad', beforeLoad);
|
|
195
|
+
loader.addHook('afterLoad', afterLoad);
|
|
196
|
+
|
|
197
|
+
loader.register('test-widget', {}, () => Promise.resolve({
|
|
198
|
+
render: jest.fn(),
|
|
199
|
+
getData: jest.fn(),
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
await loader.load('test-widget');
|
|
203
|
+
|
|
204
|
+
expect(beforeLoad).toHaveBeenCalled();
|
|
205
|
+
expect(afterLoad).toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('should call beforeUnload hook', async () => {
|
|
209
|
+
const loader = new WidgetLoader();
|
|
210
|
+
const beforeUnload = jest.fn();
|
|
211
|
+
|
|
212
|
+
loader.addHook('beforeUnload', beforeUnload);
|
|
213
|
+
|
|
214
|
+
loader.register('test-widget', {}, () => Promise.resolve({
|
|
215
|
+
render: jest.fn(),
|
|
216
|
+
getData: jest.fn(),
|
|
217
|
+
destroy: jest.fn(),
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
await loader.load('test-widget');
|
|
221
|
+
await loader.unload('test-widget');
|
|
222
|
+
|
|
223
|
+
expect(beforeUnload).toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('loadAllPluginsWithFallback()', () => {
|
|
229
|
+
let loader;
|
|
230
|
+
let tempDir;
|
|
231
|
+
|
|
232
|
+
beforeEach(async () => {
|
|
233
|
+
tempDir = await mkdtemp(join(tmpdir(), 'plugin-test-'));
|
|
234
|
+
loader = new WidgetLoader({ pluginsDir: tempDir });
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(async () => {
|
|
238
|
+
if (loader) {
|
|
239
|
+
await loader.clear();
|
|
240
|
+
}
|
|
241
|
+
if (tempDir) {
|
|
242
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('should load valid plugins successfully', async () => {
|
|
247
|
+
// Create valid plugin
|
|
248
|
+
const pluginDir = join(tempDir, 'valid-plugin');
|
|
249
|
+
await mkdir(pluginDir);
|
|
250
|
+
|
|
251
|
+
// Add package.json to mark as ESM module
|
|
252
|
+
await writeFile(join(pluginDir, 'package.json'), JSON.stringify({
|
|
253
|
+
type: 'module'
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
|
|
257
|
+
id: 'valid-plugin',
|
|
258
|
+
name: 'Valid Plugin',
|
|
259
|
+
version: '1.0.0',
|
|
260
|
+
type: 'widget',
|
|
261
|
+
config: { message: 'test' },
|
|
262
|
+
}));
|
|
263
|
+
|
|
264
|
+
await writeFile(join(pluginDir, 'index.js'), `
|
|
265
|
+
export default {
|
|
266
|
+
render: () => {},
|
|
267
|
+
getData: async () => ({ data: 'test' })
|
|
268
|
+
};
|
|
269
|
+
`);
|
|
270
|
+
|
|
271
|
+
const results = await loader.loadAllPluginsWithFallback();
|
|
272
|
+
|
|
273
|
+
expect(results.successful).toContain('valid-plugin');
|
|
274
|
+
expect(results.failed).toHaveLength(0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('should handle malformed plugin.json gracefully', async () => {
|
|
278
|
+
// Create plugin with malformed JSON
|
|
279
|
+
const pluginDir = join(tempDir, 'malformed-plugin');
|
|
280
|
+
await mkdir(pluginDir);
|
|
281
|
+
|
|
282
|
+
await writeFile(join(pluginDir, 'plugin.json'), '{ invalid json }');
|
|
283
|
+
await writeFile(join(pluginDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
|
|
284
|
+
|
|
285
|
+
const results = await loader.loadAllPluginsWithFallback();
|
|
286
|
+
|
|
287
|
+
// Should skip or fail gracefully - malformed JSON should not crash
|
|
288
|
+
expect(results.successful).not.toContain('malformed-plugin');
|
|
289
|
+
// The plugin should either fail or be skipped, not succeed
|
|
290
|
+
const wasHandled = results.failed.some(f => f.id === 'malformed-plugin') ||
|
|
291
|
+
results.skipped.includes('malformed-plugin') ||
|
|
292
|
+
results.successful.length === 0; // If nothing loaded, that's also acceptable
|
|
293
|
+
expect(wasHandled).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('should handle missing plugin.json', async () => {
|
|
297
|
+
// Create plugin directory without manifest
|
|
298
|
+
const pluginDir = join(tempDir, 'no-manifest');
|
|
299
|
+
await mkdir(pluginDir);
|
|
300
|
+
await writeFile(join(pluginDir, 'index.js'), 'export default {}');
|
|
301
|
+
|
|
302
|
+
const results = await loader.loadAllPluginsWithFallback();
|
|
303
|
+
|
|
304
|
+
// Should skip directories without plugin.json
|
|
305
|
+
expect(results.successful).not.toContain('no-manifest');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('should handle missing index.js', async () => {
|
|
309
|
+
// Create plugin with manifest but no entry point
|
|
310
|
+
const pluginDir = join(tempDir, 'no-entry');
|
|
311
|
+
await mkdir(pluginDir);
|
|
312
|
+
|
|
313
|
+
await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
|
|
314
|
+
id: 'no-entry',
|
|
315
|
+
name: 'No Entry',
|
|
316
|
+
version: '1.0.0',
|
|
317
|
+
type: 'widget',
|
|
318
|
+
}));
|
|
319
|
+
|
|
320
|
+
const results = await loader.loadAllPluginsWithFallback();
|
|
321
|
+
|
|
322
|
+
// Should skip directories without index.js
|
|
323
|
+
expect(results.successful).not.toContain('no-entry');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('should continue loading after plugin error with continueOnError', async () => {
|
|
327
|
+
// Create one valid and one invalid plugin
|
|
328
|
+
const validDir = join(tempDir, 'valid-plugin');
|
|
329
|
+
await mkdir(validDir);
|
|
330
|
+
await writeFile(join(validDir, 'plugin.json'), JSON.stringify({
|
|
331
|
+
id: 'valid-plugin',
|
|
332
|
+
name: 'Valid',
|
|
333
|
+
version: '1.0.0',
|
|
334
|
+
type: 'widget',
|
|
335
|
+
}));
|
|
336
|
+
await writeFile(join(validDir, 'index.js'), `
|
|
337
|
+
export default { render: () => {}, getData: async () => ({}) };
|
|
338
|
+
`);
|
|
339
|
+
|
|
340
|
+
const invalidDir = join(tempDir, 'invalid-plugin');
|
|
341
|
+
await mkdir(invalidDir);
|
|
342
|
+
await writeFile(join(invalidDir, 'plugin.json'), JSON.stringify({
|
|
343
|
+
id: 'invalid-plugin',
|
|
344
|
+
name: 'Invalid',
|
|
345
|
+
version: '1.0.0',
|
|
346
|
+
type: 'widget',
|
|
347
|
+
}));
|
|
348
|
+
await writeFile(join(invalidDir, 'index.js'), 'export default { invalid: true };'); // Missing required methods
|
|
349
|
+
|
|
350
|
+
const results = await loader.loadAllPluginsWithFallback({ continueOnError: true });
|
|
351
|
+
|
|
352
|
+
// Valid plugin should load despite invalid one failing
|
|
353
|
+
expect(results.successful).toContain('valid-plugin');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('should handle plugin that throws during load', async () => {
|
|
357
|
+
const pluginDir = join(tempDir, 'throwing-plugin');
|
|
358
|
+
await mkdir(pluginDir);
|
|
359
|
+
|
|
360
|
+
// Set lazyLoad: false to force immediate loading which will trigger the error
|
|
361
|
+
await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({
|
|
362
|
+
id: 'throwing-plugin',
|
|
363
|
+
name: 'Throwing',
|
|
364
|
+
version: '1.0.0',
|
|
365
|
+
type: 'widget',
|
|
366
|
+
lazyLoad: false, // Force immediate load
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
await writeFile(join(pluginDir, 'index.js'), `
|
|
370
|
+
throw new Error('Plugin failed to initialize');
|
|
371
|
+
export default {};
|
|
372
|
+
`);
|
|
373
|
+
|
|
374
|
+
// With fallbackOnError: false, errors propagate and are caught
|
|
375
|
+
const results = await loader.loadAllPluginsWithFallback({
|
|
376
|
+
continueOnError: true,
|
|
377
|
+
fallbackOnError: false,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// The plugin should fail during auto-load
|
|
381
|
+
expect(results.failed.some(f => f.id === 'throwing-plugin')).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe('validateManifest()', () => {
|
|
386
|
+
test('should validate required fields', () => {
|
|
387
|
+
const validManifest = {
|
|
388
|
+
name: 'Test Plugin',
|
|
389
|
+
version: '1.0.0',
|
|
390
|
+
entryPoint: 'index.js',
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
expect(() => validateManifest(validManifest)).not.toThrow();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('should reject missing name', () => {
|
|
397
|
+
const manifest = {
|
|
398
|
+
version: '1.0.0',
|
|
399
|
+
entryPoint: 'index.js',
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
expect(() => validateManifest(manifest)).toThrow(/Missing required fields/);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('should reject missing version', () => {
|
|
406
|
+
const manifest = {
|
|
407
|
+
name: 'Test',
|
|
408
|
+
entryPoint: 'index.js',
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
expect(() => validateManifest(manifest)).toThrow(/Missing required fields/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('should reject missing entryPoint', () => {
|
|
415
|
+
const manifest = {
|
|
416
|
+
name: 'Test',
|
|
417
|
+
version: '1.0.0',
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
expect(() => validateManifest(manifest)).toThrow(/Missing required fields/);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('should reject invalid semver format', () => {
|
|
424
|
+
const manifest = {
|
|
425
|
+
name: 'Test',
|
|
426
|
+
version: 'v1', // Invalid semver
|
|
427
|
+
entryPoint: 'index.js',
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
expect(() => validateManifest(manifest)).toThrow(/semver/);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('should accept valid semver formats', () => {
|
|
434
|
+
const versions = ['1.0.0', '0.0.1', '10.20.30', '1.0.0-beta', '1.0.0-beta.1'];
|
|
435
|
+
|
|
436
|
+
for (const version of versions) {
|
|
437
|
+
const manifest = {
|
|
438
|
+
name: 'Test',
|
|
439
|
+
version,
|
|
440
|
+
entryPoint: 'index.js',
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
expect(() => validateManifest(manifest)).not.toThrow();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe('WidgetConfigValidator', () => {
|
|
449
|
+
let validator;
|
|
450
|
+
|
|
451
|
+
beforeEach(() => {
|
|
452
|
+
validator = new WidgetConfigValidator();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe('basic sanitization', () => {
|
|
456
|
+
test('should return empty object for null/undefined', () => {
|
|
457
|
+
expect(validator.validate(null)).toEqual({});
|
|
458
|
+
expect(validator.validate(undefined)).toEqual({});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('should reject non-object config', () => {
|
|
462
|
+
expect(() => validator.validate('string')).toThrow('must be an object');
|
|
463
|
+
expect(() => validator.validate(123)).toThrow('must be an object');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('should pass through valid objects', () => {
|
|
467
|
+
const config = { name: 'test', count: 5, enabled: true };
|
|
468
|
+
expect(validator.validate(config)).toEqual(config);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe('string handling', () => {
|
|
473
|
+
test('should strip null bytes from strings', () => {
|
|
474
|
+
const config = { message: 'hello\0world' };
|
|
475
|
+
const sanitized = validator.validate(config);
|
|
476
|
+
|
|
477
|
+
expect(sanitized.message).toBe('helloworld');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('should truncate long strings', () => {
|
|
481
|
+
const longString = 'a'.repeat(2000);
|
|
482
|
+
const config = { message: longString };
|
|
483
|
+
const sanitized = validator.validate(config);
|
|
484
|
+
|
|
485
|
+
expect(sanitized.message.length).toBe(1000);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe('number handling', () => {
|
|
490
|
+
test('should reject NaN', () => {
|
|
491
|
+
const config = { value: NaN };
|
|
492
|
+
const sanitized = validator.validate(config);
|
|
493
|
+
|
|
494
|
+
expect(sanitized.value).toBe(0);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('should reject Infinity', () => {
|
|
498
|
+
const config = { value: Infinity };
|
|
499
|
+
const sanitized = validator.validate(config);
|
|
500
|
+
|
|
501
|
+
expect(sanitized.value).toBe(0);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('should accept valid numbers', () => {
|
|
505
|
+
const config = { int: 42, float: 3.14, negative: -10 };
|
|
506
|
+
const sanitized = validator.validate(config);
|
|
507
|
+
|
|
508
|
+
expect(sanitized).toEqual(config);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe('array handling', () => {
|
|
513
|
+
test('should limit array length', () => {
|
|
514
|
+
const validator = new WidgetConfigValidator({ maxArrayLength: 5 });
|
|
515
|
+
const config = { items: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] };
|
|
516
|
+
const sanitized = validator.validate(config);
|
|
517
|
+
|
|
518
|
+
expect(sanitized.items.length).toBe(5);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('should sanitize array items', () => {
|
|
522
|
+
const config = { items: ['normal', 'with\0null', 123] };
|
|
523
|
+
const sanitized = validator.validate(config);
|
|
524
|
+
|
|
525
|
+
expect(sanitized.items).toEqual(['normal', 'withnull', 123]);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe('depth limiting', () => {
|
|
530
|
+
test('should handle deeply nested objects gracefully', () => {
|
|
531
|
+
// The validator catches depth errors and returns null for values that exceed depth
|
|
532
|
+
const validator = new WidgetConfigValidator({ maxDepth: 3 });
|
|
533
|
+
const config = {
|
|
534
|
+
level1: {
|
|
535
|
+
level2: {
|
|
536
|
+
level3: {
|
|
537
|
+
level4: {
|
|
538
|
+
level5: 'too deep',
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// Should not throw; instead deep values become null
|
|
546
|
+
const sanitized = validator.validate(config);
|
|
547
|
+
expect(sanitized).toBeDefined();
|
|
548
|
+
expect(sanitized.level1).toBeDefined();
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('schema validation', () => {
|
|
553
|
+
test('should only allow whitelisted keys with schema', () => {
|
|
554
|
+
const schema = {
|
|
555
|
+
properties: {
|
|
556
|
+
name: { type: 'string' },
|
|
557
|
+
count: { type: 'number' },
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const config = { name: 'test', count: 5, malicious: 'hack' };
|
|
562
|
+
const sanitized = validator.validate(config, schema);
|
|
563
|
+
|
|
564
|
+
expect(sanitized).toEqual({ name: 'test', count: 5 });
|
|
565
|
+
expect(sanitized.malicious).toBeUndefined();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('should handle schema-based validation', () => {
|
|
569
|
+
const schema = {
|
|
570
|
+
properties: {
|
|
571
|
+
timeout: { type: 'number', default: 5000 },
|
|
572
|
+
name: { type: 'string' },
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const config = { timeout: 'invalid', name: 'test' };
|
|
577
|
+
const sanitized = validator.validate(config, schema);
|
|
578
|
+
|
|
579
|
+
// Strings are valid, so 'invalid' stays as-is (string type is in allowedTypes)
|
|
580
|
+
expect(sanitized.timeout).toBe('invalid');
|
|
581
|
+
expect(sanitized.name).toBe('test');
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe('sanitizeWidgetConfig()', () => {
|
|
587
|
+
test('should sanitize malicious inputs', () => {
|
|
588
|
+
const malicious = {
|
|
589
|
+
__proto__: { polluted: true },
|
|
590
|
+
constructor: { prototype: { polluted: true } },
|
|
591
|
+
message: 'normal string',
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const sanitized = sanitizeWidgetConfig(malicious);
|
|
595
|
+
|
|
596
|
+
// Should not have polluted prototype
|
|
597
|
+
expect(sanitized.message).toBe('normal string');
|
|
598
|
+
expect({}.polluted).toBeUndefined();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('should handle special string values', () => {
|
|
602
|
+
const config = {
|
|
603
|
+
script: '<script>alert("xss")</script>',
|
|
604
|
+
sql: "'; DROP TABLE users; --",
|
|
605
|
+
path: '../../../etc/passwd',
|
|
606
|
+
unicode: '\u0000\u0001\u0002', // null byte + two control chars
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const sanitized = sanitizeWidgetConfig(config);
|
|
610
|
+
|
|
611
|
+
// Should sanitize null bytes but keep other content
|
|
612
|
+
expect(sanitized.script).toBe('<script>alert("xss")</script>');
|
|
613
|
+
expect(sanitized.sql).toBe("'; DROP TABLE users; --");
|
|
614
|
+
expect(sanitized.path).toBe('../../../etc/passwd');
|
|
615
|
+
// Only null byte (\u0000) is stripped; \u0001 and \u0002 remain
|
|
616
|
+
expect(sanitized.unicode).toBe('\u0001\u0002');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test('should handle edge cases', () => {
|
|
620
|
+
expect(sanitizeWidgetConfig(null)).toEqual({});
|
|
621
|
+
expect(sanitizeWidgetConfig(undefined)).toEqual({});
|
|
622
|
+
expect(sanitizeWidgetConfig({})).toEqual({});
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
describe('validateWidgetConfig()', () => {
|
|
627
|
+
test('should return valid result for valid config', () => {
|
|
628
|
+
const schema = {
|
|
629
|
+
properties: {
|
|
630
|
+
name: { type: 'string' },
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const result = validateWidgetConfig({ name: 'test' }, schema);
|
|
635
|
+
|
|
636
|
+
expect(result.valid).toBe(true);
|
|
637
|
+
expect(result.errors).toEqual([]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('should handle deeply nested config gracefully', () => {
|
|
641
|
+
// Default maxDepth is 10, so 11 levels should be handled gracefully
|
|
642
|
+
const config = {
|
|
643
|
+
level1: {
|
|
644
|
+
level2: {
|
|
645
|
+
level3: {
|
|
646
|
+
level4: {
|
|
647
|
+
level5: {
|
|
648
|
+
level6: {
|
|
649
|
+
level7: {
|
|
650
|
+
level8: {
|
|
651
|
+
level9: {
|
|
652
|
+
level10: {
|
|
653
|
+
level11: 'too deep',
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// The validator handles depth errors gracefully by returning null for deep values
|
|
667
|
+
const result = validateWidgetConfig(config);
|
|
668
|
+
|
|
669
|
+
// Should either pass (with deep values nulled) or return errors
|
|
670
|
+
// depending on implementation details
|
|
671
|
+
expect(result).toBeDefined();
|
|
672
|
+
expect(result).toHaveProperty('valid');
|
|
673
|
+
expect(result).toHaveProperty('errors');
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe('BaseWidget', () => {
|
|
678
|
+
test('should initialize with default values', () => {
|
|
679
|
+
const widget = new BaseWidget();
|
|
680
|
+
|
|
681
|
+
expect(widget.id).toBeDefined();
|
|
682
|
+
expect(widget.name).toBe('Unnamed Widget');
|
|
683
|
+
expect(widget.loaded).toBe(false);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('should accept options', () => {
|
|
687
|
+
const widget = new BaseWidget({
|
|
688
|
+
id: 'test-widget',
|
|
689
|
+
name: 'Test Widget',
|
|
690
|
+
config: { setting: 'value' },
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(widget.id).toBe('test-widget');
|
|
694
|
+
expect(widget.name).toBe('Test Widget');
|
|
695
|
+
expect(widget.config).toEqual({ setting: 'value' });
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('should throw on create() by default', async () => {
|
|
699
|
+
const widget = new BaseWidget();
|
|
700
|
+
|
|
701
|
+
await expect(widget.create()).rejects.toThrow('create() must be implemented');
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test('should provide lifecycle methods', async () => {
|
|
705
|
+
const widget = new BaseWidget();
|
|
706
|
+
|
|
707
|
+
await widget.init();
|
|
708
|
+
expect(widget.loaded).toBe(true);
|
|
709
|
+
|
|
710
|
+
await widget.destroy();
|
|
711
|
+
expect(widget.loaded).toBe(false);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test('should provide getMetadata()', () => {
|
|
715
|
+
const widget = new BaseWidget({
|
|
716
|
+
id: 'test',
|
|
717
|
+
name: 'Test',
|
|
718
|
+
description: 'A test widget',
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const metadata = widget.getMetadata();
|
|
722
|
+
|
|
723
|
+
expect(metadata.id).toBe('test');
|
|
724
|
+
expect(metadata.name).toBe('Test');
|
|
725
|
+
expect(metadata.description).toBe('A test widget');
|
|
726
|
+
expect(metadata.loaded).toBe(false);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
describe('PluginAPI', () => {
|
|
731
|
+
let api;
|
|
732
|
+
|
|
733
|
+
beforeEach(() => {
|
|
734
|
+
api = new PluginAPI();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
describe('extension points', () => {
|
|
738
|
+
test('should register extension points', () => {
|
|
739
|
+
api.registerExtensionPoint('myExtension', { description: 'Test extension' });
|
|
740
|
+
|
|
741
|
+
expect(api.extensions.has('myExtension')).toBe(true);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test('should add handlers to extension points', () => {
|
|
745
|
+
api.registerExtensionPoint('myExtension');
|
|
746
|
+
const handler = jest.fn();
|
|
747
|
+
|
|
748
|
+
api.extend('myExtension', handler);
|
|
749
|
+
api.executeExtension('myExtension', 'arg1', 'arg2');
|
|
750
|
+
|
|
751
|
+
expect(handler).toHaveBeenCalledWith('arg1', 'arg2');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test('should throw for non-existent extension point', () => {
|
|
755
|
+
expect(() => api.extend('nonexistent', jest.fn())).toThrow(/not found/);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('should enforce single handler when multiple is false', () => {
|
|
759
|
+
api.registerExtensionPoint('single', { multiple: false });
|
|
760
|
+
api.extend('single', jest.fn());
|
|
761
|
+
|
|
762
|
+
expect(() => api.extend('single', jest.fn())).toThrow(/only allows one handler/);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('should sort handlers by priority', async () => {
|
|
766
|
+
api.registerExtensionPoint('ordered');
|
|
767
|
+
const order = [];
|
|
768
|
+
|
|
769
|
+
api.extend('ordered', () => order.push(2), { priority: 100 });
|
|
770
|
+
api.extend('ordered', () => order.push(1), { priority: 1 });
|
|
771
|
+
api.extend('ordered', () => order.push(3), { priority: 200 });
|
|
772
|
+
|
|
773
|
+
await api.executeExtension('ordered');
|
|
774
|
+
|
|
775
|
+
expect(order).toEqual([1, 2, 3]);
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
describe('data providers', () => {
|
|
780
|
+
test('should register and call data providers', async () => {
|
|
781
|
+
const provider = jest.fn().mockResolvedValue({ data: 'test' });
|
|
782
|
+
api.registerDataProvider('myData', provider);
|
|
783
|
+
|
|
784
|
+
const result = await api.getData('myData');
|
|
785
|
+
|
|
786
|
+
expect(provider).toHaveBeenCalled();
|
|
787
|
+
expect(result).toEqual({ data: 'test' });
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test('should check if provider exists', () => {
|
|
791
|
+
api.registerDataProvider('exists', jest.fn());
|
|
792
|
+
|
|
793
|
+
expect(api.hasDataProvider('exists')).toBe(true);
|
|
794
|
+
expect(api.hasDataProvider('nonexistent')).toBe(false);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('should throw for non-existent provider', async () => {
|
|
798
|
+
await expect(api.getData('nonexistent')).rejects.toThrow(/not found/);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test('should require provider to be a function', () => {
|
|
802
|
+
expect(() => api.registerDataProvider('invalid', 'not a function')).toThrow(/must be a function/);
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
describe('hooks', () => {
|
|
807
|
+
test('should add and execute hooks', async () => {
|
|
808
|
+
const hook = jest.fn();
|
|
809
|
+
api.addHook('init', hook);
|
|
810
|
+
|
|
811
|
+
await api.executeHooks('init', { value: 1 });
|
|
812
|
+
|
|
813
|
+
expect(hook).toHaveBeenCalledWith({ value: 1 });
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test('should remove one-time hooks after execution', async () => {
|
|
817
|
+
const hook = jest.fn();
|
|
818
|
+
api.addHook('once', hook, { once: true });
|
|
819
|
+
|
|
820
|
+
await api.executeHooks('once', {});
|
|
821
|
+
await api.executeHooks('once', {});
|
|
822
|
+
|
|
823
|
+
expect(hook).toHaveBeenCalledTimes(1);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test('should sort hooks by priority', async () => {
|
|
827
|
+
const order = [];
|
|
828
|
+
|
|
829
|
+
api.addHook('ordered', () => order.push(2), { priority: 100 });
|
|
830
|
+
api.addHook('ordered', () => order.push(1), { priority: 1 });
|
|
831
|
+
api.addHook('ordered', () => order.push(3), { priority: 200 });
|
|
832
|
+
|
|
833
|
+
await api.executeHooks('ordered', {});
|
|
834
|
+
|
|
835
|
+
expect(order).toEqual([1, 2, 3]);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test('should return modified context', async () => {
|
|
839
|
+
api.addHook('transform', (ctx) => { ctx.value *= 2; });
|
|
840
|
+
api.addHook('transform', (ctx) => { ctx.value += 10; });
|
|
841
|
+
|
|
842
|
+
const result = await api.executeHooks('transform', { value: 5 });
|
|
843
|
+
|
|
844
|
+
expect(result.value).toBe(20);
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
describe('getInfo()', () => {
|
|
849
|
+
test('should return API information', () => {
|
|
850
|
+
api.registerExtensionPoint('ext1');
|
|
851
|
+
api.registerDataProvider('data1', jest.fn());
|
|
852
|
+
api.addHook('hook1', jest.fn());
|
|
853
|
+
|
|
854
|
+
const info = api.getInfo();
|
|
855
|
+
|
|
856
|
+
expect(info.version).toBe('1.0.0');
|
|
857
|
+
expect(info.extensionPoints).toContain('ext1');
|
|
858
|
+
expect(info.dataProviders).toContain('data1');
|
|
859
|
+
expect(info.hooks).toContain('hook1');
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
describe('Path Validation', () => {
|
|
865
|
+
let tempDir;
|
|
866
|
+
|
|
867
|
+
beforeEach(async () => {
|
|
868
|
+
tempDir = await mkdtemp(join(tmpdir(), 'path-validation-test-'));
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
afterEach(async () => {
|
|
872
|
+
if (tempDir) {
|
|
873
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
describe('validatePluginName()', () => {
|
|
878
|
+
test('should accept valid plugin names', () => {
|
|
879
|
+
const validNames = ['my-widget', 'test_plugin', 'widget123', 'MyWidget', 'my-widget-v2'];
|
|
880
|
+
|
|
881
|
+
for (const name of validNames) {
|
|
882
|
+
const result = validatePluginName(name);
|
|
883
|
+
expect(result.valid).toBe(true);
|
|
884
|
+
expect(result.error).toBeNull();
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test('should reject empty names', () => {
|
|
889
|
+
const result = validatePluginName('');
|
|
890
|
+
expect(result.valid).toBe(false);
|
|
891
|
+
expect(result.error).toContain('empty');
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test('should reject null/undefined names', () => {
|
|
895
|
+
expect(validatePluginName(null).valid).toBe(false);
|
|
896
|
+
expect(validatePluginName(undefined).valid).toBe(false);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test('should reject names starting with non-alphanumeric', () => {
|
|
900
|
+
const result = validatePluginName('-widget');
|
|
901
|
+
expect(result.valid).toBe(false);
|
|
902
|
+
expect(result.error).toContain('must start with alphanumeric');
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test('should reject names with invalid characters', () => {
|
|
906
|
+
const result = validatePluginName('my@widget');
|
|
907
|
+
expect(result.valid).toBe(false);
|
|
908
|
+
expect(result.error).toContain('alphanumeric');
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test('should reject reserved names', () => {
|
|
912
|
+
const reserved = ['node_modules', '.git', 'package.json'];
|
|
913
|
+
|
|
914
|
+
for (const name of reserved) {
|
|
915
|
+
const result = validatePluginName(name);
|
|
916
|
+
expect(result.valid).toBe(false);
|
|
917
|
+
expect(result.error).toContain('reserved');
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test('should reject names over 100 characters', () => {
|
|
922
|
+
const longName = 'a'.repeat(101);
|
|
923
|
+
const result = validatePluginName(longName);
|
|
924
|
+
expect(result.valid).toBe(false);
|
|
925
|
+
expect(result.error).toContain('too long');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test('should trim whitespace from names', () => {
|
|
929
|
+
const result = validatePluginName(' my-widget ');
|
|
930
|
+
expect(result.valid).toBe(true);
|
|
931
|
+
});
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
describe('validatePluginPath()', () => {
|
|
935
|
+
test('should validate simple paths within allowed directory', () => {
|
|
936
|
+
const result = validatePluginPath('my-widget', {
|
|
937
|
+
allowedDirs: ['/plugins'],
|
|
938
|
+
allowAbsolute: false,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
expect(result.valid).toBe(true);
|
|
942
|
+
expect(result.path).toBe('/plugins/my-widget');
|
|
943
|
+
expect(result.error).toBeNull();
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test('should reject paths with traversal attempts', () => {
|
|
947
|
+
const traversalPaths = ['../etc/passwd', 'widget/../../etc', '..', 'widget/../..'];
|
|
948
|
+
|
|
949
|
+
for (const testPath of traversalPaths) {
|
|
950
|
+
const result = validatePluginPath(testPath, {
|
|
951
|
+
allowedDirs: ['/plugins'],
|
|
952
|
+
allowAbsolute: false,
|
|
953
|
+
});
|
|
954
|
+
expect(result.valid).toBe(false);
|
|
955
|
+
expect(result.error).toContain('traversal');
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
test('should reject absolute paths when not allowed', () => {
|
|
960
|
+
const result = validatePluginPath('/etc/passwd', {
|
|
961
|
+
allowedDirs: ['/plugins'],
|
|
962
|
+
allowAbsolute: false,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
expect(result.valid).toBe(false);
|
|
966
|
+
expect(result.error).toContain('Absolute paths');
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test('should accept absolute paths when allowed', () => {
|
|
970
|
+
const result = validatePluginPath('/plugins/my-widget', {
|
|
971
|
+
allowedDirs: ['/plugins'],
|
|
972
|
+
allowAbsolute: true,
|
|
973
|
+
mustExist: false,
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
expect(result.valid).toBe(true);
|
|
977
|
+
expect(result.path).toBe('/plugins/my-widget');
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test('should reject paths outside allowed directories', () => {
|
|
981
|
+
const result = validatePluginPath('/etc/passwd', {
|
|
982
|
+
allowedDirs: ['/plugins'],
|
|
983
|
+
allowAbsolute: true,
|
|
984
|
+
mustExist: false,
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
expect(result.valid).toBe(false);
|
|
988
|
+
expect(result.error).toContain('outside allowed');
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test('should reject paths with null bytes', () => {
|
|
992
|
+
const result = validatePluginPath('widget\0/exploit', {
|
|
993
|
+
allowedDirs: ['/plugins'],
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
expect(result.valid).toBe(false);
|
|
997
|
+
expect(result.error).toContain('null bytes');
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test('should reject paths with invalid characters', () => {
|
|
1001
|
+
const result = validatePluginPath('my@widget', {
|
|
1002
|
+
allowedDirs: ['/plugins'],
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
expect(result.valid).toBe(false);
|
|
1006
|
+
expect(result.error).toContain('Invalid characters');
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test('should reject hidden files/directories', () => {
|
|
1010
|
+
const result = validatePluginPath('.hidden-widget', {
|
|
1011
|
+
allowedDirs: ['/plugins'],
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
expect(result.valid).toBe(false);
|
|
1015
|
+
expect(result.error).toContain('Hidden files');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('should handle missing allowedDirs', () => {
|
|
1019
|
+
const result = validatePluginPath('my-widget', {});
|
|
1020
|
+
|
|
1021
|
+
expect(result.valid).toBe(true);
|
|
1022
|
+
expect(result.path).toBeDefined();
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test('should validate existing directories', () => {
|
|
1026
|
+
// This test uses the actual temp directory
|
|
1027
|
+
const result = validatePluginPath('.', {
|
|
1028
|
+
allowedDirs: [tempDir],
|
|
1029
|
+
allowAbsolute: false,
|
|
1030
|
+
mustExist: true,
|
|
1031
|
+
expectedType: 'directory',
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// The test should pass since tempDir exists
|
|
1035
|
+
if (result.valid) {
|
|
1036
|
+
expect(result.path).toBeDefined();
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
test('should return error for non-existent paths when mustExist is true', () => {
|
|
1041
|
+
const result = validatePluginPath('non-existent-path-xyz', {
|
|
1042
|
+
allowedDirs: ['/tmp'],
|
|
1043
|
+
mustExist: true,
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
expect(result.valid).toBe(false);
|
|
1047
|
+
expect(result.error).toContain('does not exist');
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
test('should check file type when expectedType is set', () => {
|
|
1051
|
+
// Test with a file that exists (tempDir itself)
|
|
1052
|
+
const fileResult = validatePluginPath('.', {
|
|
1053
|
+
allowedDirs: [tempDir],
|
|
1054
|
+
mustExist: true,
|
|
1055
|
+
expectedType: 'file',
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// Should fail because . is a directory, not a file
|
|
1059
|
+
expect(fileResult.valid).toBe(false);
|
|
1060
|
+
expect(fileResult.error).toContain('not a file');
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
describe('WidgetLoader with path validation', () => {
|
|
1066
|
+
let loader;
|
|
1067
|
+
let tempDir;
|
|
1068
|
+
|
|
1069
|
+
beforeEach(async () => {
|
|
1070
|
+
tempDir = await mkdtemp(join(tmpdir(), 'path-validation-test-'));
|
|
1071
|
+
loader = new WidgetLoader({ pluginsDir: tempDir });
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
afterEach(async () => {
|
|
1075
|
+
if (loader) {
|
|
1076
|
+
await loader.clear();
|
|
1077
|
+
}
|
|
1078
|
+
if (tempDir) {
|
|
1079
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test('should skip plugins with invalid directory names during discovery', async () => {
|
|
1084
|
+
// Create a plugin with an invalid name (contains path traversal attempt)
|
|
1085
|
+
const invalidDir = join(tempDir, '..invalid');
|
|
1086
|
+
// Can't actually create a directory starting with .. on most systems
|
|
1087
|
+
// So we'll test with a different invalid character
|
|
1088
|
+
const badDir = join(tempDir, 'bad@name');
|
|
1089
|
+
await mkdir(badDir).catch(() => {});
|
|
1090
|
+
|
|
1091
|
+
if (existsSync(badDir)) {
|
|
1092
|
+
await writeFile(join(badDir, 'plugin.json'), JSON.stringify({
|
|
1093
|
+
id: 'bad-name',
|
|
1094
|
+
name: 'Bad Name',
|
|
1095
|
+
version: '1.0.0',
|
|
1096
|
+
type: 'widget',
|
|
1097
|
+
}));
|
|
1098
|
+
await writeFile(join(badDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
|
|
1099
|
+
|
|
1100
|
+
const discovered = await loader.discoverPlugins();
|
|
1101
|
+
expect(discovered.some(p => p.id === 'bad-name')).toBe(false);
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test('should load valid plugins with sanitized names', async () => {
|
|
1106
|
+
const validDir = join(tempDir, 'valid-widget');
|
|
1107
|
+
await mkdir(validDir);
|
|
1108
|
+
|
|
1109
|
+
await writeFile(join(validDir, 'plugin.json'), JSON.stringify({
|
|
1110
|
+
id: 'valid-widget',
|
|
1111
|
+
name: 'Valid Widget',
|
|
1112
|
+
version: '1.0.0',
|
|
1113
|
+
type: 'widget',
|
|
1114
|
+
}));
|
|
1115
|
+
|
|
1116
|
+
await writeFile(join(validDir, 'index.js'), `
|
|
1117
|
+
export default {
|
|
1118
|
+
render: () => {},
|
|
1119
|
+
getData: async () => ({ data: 'test' })
|
|
1120
|
+
};
|
|
1121
|
+
`);
|
|
1122
|
+
|
|
1123
|
+
const discovered = await loader.discoverPlugins();
|
|
1124
|
+
expect(discovered.some(p => p.id === 'valid-widget')).toBe(true);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
test('should reject loading plugin from outside plugins directory', async () => {
|
|
1128
|
+
// Attempt to load a plugin from /tmp or similar
|
|
1129
|
+
const outsideDir = join(tmpdir(), 'outside-plugin');
|
|
1130
|
+
await mkdir(outsideDir, { recursive: true });
|
|
1131
|
+
|
|
1132
|
+
await writeFile(join(outsideDir, 'plugin.json'), JSON.stringify({
|
|
1133
|
+
id: 'outside-plugin',
|
|
1134
|
+
name: 'Outside Plugin',
|
|
1135
|
+
version: '1.0.0',
|
|
1136
|
+
type: 'widget',
|
|
1137
|
+
}));
|
|
1138
|
+
|
|
1139
|
+
await writeFile(join(outsideDir, 'index.js'), 'export default { render: () => {}, getData: async () => ({}) };');
|
|
1140
|
+
|
|
1141
|
+
// Try to load from outside the plugins directory
|
|
1142
|
+
await expect(loader.loadPlugin(outsideDir)).rejects.toThrow(/outside allowed directories/);
|
|
1143
|
+
|
|
1144
|
+
// Cleanup
|
|
1145
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
1146
|
+
});
|
|
1147
|
+
});
|