@pixlcore/xycli 1.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/README.md +543 -0
- package/docs/help.md +2587 -0
- package/docs/screenshots.md +219 -0
- package/docs/sync.md +920 -0
- package/docs/tutorial.md +204 -0
- package/index.js +444 -0
- package/lib/alerts.js +735 -0
- package/lib/apikey.js +425 -0
- package/lib/buckets.js +607 -0
- package/lib/categories.js +323 -0
- package/lib/channels.js +290 -0
- package/lib/config.js +86 -0
- package/lib/dashboard.js +237 -0
- package/lib/doc.js +131 -0
- package/lib/events.js +1314 -0
- package/lib/groups.js +1038 -0
- package/lib/jobs.js +1254 -0
- package/lib/log.js +125 -0
- package/lib/marketplace.js +386 -0
- package/lib/monitors.js +412 -0
- package/lib/plugins.js +491 -0
- package/lib/secrets.js +441 -0
- package/lib/servers.js +1502 -0
- package/lib/snapshots.js +498 -0
- package/lib/sync.js +963 -0
- package/lib/system.js +612 -0
- package/lib/tags.js +250 -0
- package/lib/tickets.js +681 -0
- package/lib/transfer.js +447 -0
- package/lib/utils.js +2745 -0
- package/lib/webhook.js +414 -0
- package/logo.webp +0 -0
- package/package.json +38 -0
- package/test/README.md +97 -0
- package/test/api-validation.test.js +170 -0
- package/test/categories.test.js +246 -0
- package/test/channels.test.js +209 -0
- package/test/config.test.js +96 -0
- package/test/doc.test.js +84 -0
- package/test/groups.test.js +211 -0
- package/test/help.test.js +26 -0
- package/test/helpers/common.js +127 -0
- package/test/helpers/pagination-preload.cjs +18 -0
- package/test/log.test.js +103 -0
- package/test/markdown.test.js +61 -0
- package/test/marketplace.test.js +229 -0
- package/test/monitors.test.js +227 -0
- package/test/pagination.test.js +138 -0
- package/test/plugins.test.js +351 -0
- package/test/secrets.test.js +271 -0
- package/test/servers.test.js +303 -0
- package/test/snapshots.test.js +167 -0
- package/test/sync-unit.test.js +446 -0
- package/test/sync.test.js +542 -0
- package/test/system.test.js +94 -0
- package/test/tags.test.js +230 -0
- package/test/tickets.test.js +261 -0
- package/test/toast.test.js +73 -0
- package/test/transfer-apikey.test.js +96 -0
- package/test/transfer-workflow.test.js +122 -0
- package/test/transfer.test.js +314 -0
- package/test/webhook.test.js +332 -0
- package/test.js +49 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const Path = require('node:path');
|
|
5
|
+
const { loadTestConfig, createCheck, createTempDir, xy, json, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
|
|
6
|
+
|
|
7
|
+
test('plugins', async t => {
|
|
8
|
+
loadTestConfig();
|
|
9
|
+
const check = createCheck(t);
|
|
10
|
+
|
|
11
|
+
// Exercise definitions only. These fixtures are never attached to Events,
|
|
12
|
+
// Actions, Monitors, or Scheduler triggers, and no Plugin test API is invoked.
|
|
13
|
+
const stamp = Date.now();
|
|
14
|
+
const prefix = 'cli_plugin_test_' + stamp;
|
|
15
|
+
const title = 'CLI Plugin Test ' + stamp;
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
const eventID = prefix + '_event';
|
|
18
|
+
const monitorID = prefix + '_monitor';
|
|
19
|
+
const actionID = prefix + '_action';
|
|
20
|
+
const schedulerID = prefix + '_scheduler';
|
|
21
|
+
const temp = createTempDir(t, 'xycli-plugin-test-');
|
|
22
|
+
|
|
23
|
+
[eventID, monitorID, actionID, schedulerID].forEach(id => ids.add(id));
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const original = (await apiCall('getPlugins')).rows;
|
|
27
|
+
const multi = await apiCall('getMultiple', { lists: 'all' });
|
|
28
|
+
const group = multi.groups[0];
|
|
29
|
+
assert.ok(group, 'Server group required');
|
|
30
|
+
|
|
31
|
+
const scriptFile = Path.join(temp, 'plugin.js');
|
|
32
|
+
const script = "console.log(JSON.stringify({ xy: 1, code: 0 }));\n";
|
|
33
|
+
fs.writeFileSync(scriptFile, script);
|
|
34
|
+
|
|
35
|
+
await check('dry Event Plugin creation fills defaults', () => {
|
|
36
|
+
const req = json(['plugin', 'create', '--id', eventID, '--title', title + ' Event', '--command', 'node', '--dry']);
|
|
37
|
+
assert.equal(req.type, 'event');
|
|
38
|
+
assert.equal(req.enabled, true);
|
|
39
|
+
assert.equal(req.kill, 'parent');
|
|
40
|
+
assert.equal(req.runner, false);
|
|
41
|
+
assert.deepEqual(req.params, []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await check('dry creation does not persist', () => assert.deepEqual(json(['plugins', '--id', eventID]), []));
|
|
45
|
+
|
|
46
|
+
const createdEvent = json([
|
|
47
|
+
'plugin', 'create', '--id', eventID, '--title', title + ' Event',
|
|
48
|
+
'--type', 'event', '--command', 'node', '--script', '@' + scriptFile,
|
|
49
|
+
'--enabled', 'false', '--kill', 'all', '--runner', 'true',
|
|
50
|
+
'--uid', 'worker', '--gid', 'workers', '--icon', 'code-braces', '--notes', 'Event notes',
|
|
51
|
+
'--param', '{"id":"name","title":"Name","type":"text","variant":"text","value":"World","required":true}',
|
|
52
|
+
'--param', '{"id":"loud","title":"Loud","type":"checkbox","value":false}',
|
|
53
|
+
'--param', '{"id":"tool","title":"Tool","type":"toolset","data":{"tools":[{"id":"sample","title":"Sample Tool","fields":[{"id":"message","title":"Message","type":"text","value":"Hello"}]},{"id":"alternate","title":"Alternate Tool","fields":[{"id":"alternate_count","title":"Alternate Count","type":"text","variant":"number","value":2}]}]}}'
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
await check('create Event Plugin persists all settings', () => {
|
|
57
|
+
assert.equal(createdEvent.id, eventID);
|
|
58
|
+
assert.equal(createdEvent.type, 'event');
|
|
59
|
+
assert.equal(createdEvent.enabled, false);
|
|
60
|
+
assert.equal(createdEvent.command, 'node');
|
|
61
|
+
assert.equal(createdEvent.script, script);
|
|
62
|
+
assert.equal(createdEvent.kill, 'all');
|
|
63
|
+
assert.equal(createdEvent.runner, true);
|
|
64
|
+
assert.equal(createdEvent.uid, 'worker');
|
|
65
|
+
assert.equal(createdEvent.gid, 'workers');
|
|
66
|
+
assert.equal(createdEvent.revision, 1);
|
|
67
|
+
assert.deepEqual(createdEvent.params.map(param => param.id), ['name', 'loud', 'tool']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const createdMonitor = json([
|
|
71
|
+
'plugin', 'create', '--id', monitorID, '--title', title + ' Monitor',
|
|
72
|
+
'--type', 'monitor', '--command', '/bin/sh', '--script', 'echo 42',
|
|
73
|
+
'--plugin_format', 'json', '--groups', group.id, '--group', group.id,
|
|
74
|
+
'--quick', 'true', '--notes', 'Monitor notes'
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
await check('create Monitor Plugin uses monitor-only settings', () => {
|
|
78
|
+
assert.equal(createdMonitor.type, 'monitor');
|
|
79
|
+
assert.equal(createdMonitor.format, 'json');
|
|
80
|
+
assert.equal(createdMonitor.quick, true);
|
|
81
|
+
assert.deepEqual(createdMonitor.groups, [group.id]);
|
|
82
|
+
assert.deepEqual(createdMonitor.params, []);
|
|
83
|
+
assert.ok(!('kill' in createdMonitor));
|
|
84
|
+
assert.ok(!('runner' in createdMonitor));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const createdAction = json([
|
|
88
|
+
'plugin', 'create', '--id', actionID, '--title', title + ' Action',
|
|
89
|
+
'--type', 'action', '--command', 'python3', '--script', 'print("ok")',
|
|
90
|
+
'--param', '{"id":"message","title":"Message","type":"textarea","value":"Hello"}'
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
await check('create Action Plugin supports parameters', () => {
|
|
94
|
+
assert.equal(createdAction.type, 'action');
|
|
95
|
+
assert.equal(createdAction.params[0].id, 'message');
|
|
96
|
+
assert.ok(!('kill' in createdAction));
|
|
97
|
+
assert.ok(!('quick' in createdAction));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const schedulerFile = Path.join(temp, 'scheduler.json');
|
|
101
|
+
fs.writeFileSync(schedulerFile, JSON.stringify({
|
|
102
|
+
id: schedulerID,
|
|
103
|
+
title: title + ' Scheduler',
|
|
104
|
+
type: 'scheduler',
|
|
105
|
+
command: 'node',
|
|
106
|
+
script: 'console.log("[]")',
|
|
107
|
+
params: [{ id: 'calendar', title: 'Calendar', type: 'select', value: 'Work, Holiday' }],
|
|
108
|
+
notes: 'Scheduler notes'
|
|
109
|
+
}));
|
|
110
|
+
const createdScheduler = json(['plugin', 'create', '--json', '@' + schedulerFile]);
|
|
111
|
+
|
|
112
|
+
await check('create Scheduler Plugin from JSON file', () => {
|
|
113
|
+
assert.equal(createdScheduler.id, schedulerID);
|
|
114
|
+
assert.equal(createdScheduler.type, 'scheduler');
|
|
115
|
+
assert.equal(createdScheduler.params[0].id, 'calendar');
|
|
116
|
+
assert.deepEqual(createdScheduler.groups, []);
|
|
117
|
+
assert.equal(createdScheduler.format, '');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await check('all four Plugin types were created without Events', async () => {
|
|
121
|
+
const rows = (await apiCall('getPlugins')).rows.filter(plugin => ids.has(plugin.id));
|
|
122
|
+
assert.deepEqual(rows.map(plugin => plugin.type).sort(), ['action', 'event', 'monitor', 'scheduler']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await check('exact ID get', () => assert.deepEqual(json(['plugin', eventID]), createdEvent));
|
|
126
|
+
|
|
127
|
+
await check('fuzzy title get', () => assert.equal(json(['plugin', 'get', '--title', (title + ' Action').toLowerCase()]).id, actionID));
|
|
128
|
+
|
|
129
|
+
await check('named ID get', () => assert.equal(json(['plugin', 'get', '--id', schedulerID]).id, schedulerID));
|
|
130
|
+
|
|
131
|
+
await check('combined list filters', () => assert.deepEqual(json(['plugins', title, '--type', 'event', '--enabled', 'false']), [createdEvent]));
|
|
132
|
+
|
|
133
|
+
await check('singular list command', () => assert.equal(json(['plugin', 'list', title, '--type', 'monitor']).length, 1));
|
|
134
|
+
|
|
135
|
+
await check('JSON list ignores pagination as filters', () => assert.deepEqual(json(['plugins', '--id', eventID, '--limit', '1', '--page', '2']), [createdEvent]));
|
|
136
|
+
|
|
137
|
+
await check('alphabetical list ordering', () => {
|
|
138
|
+
const rows = json(['plugins', title]);
|
|
139
|
+
assert.ok(rows.every((row, idx) => !idx || rows[idx - 1].title.toLowerCase().localeCompare(row.title.toLowerCase()) <= 0));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await check('human list pagination and suggestions', () => {
|
|
143
|
+
const out = xy(['plugins', '--limit', '1', '--page', '2']);
|
|
144
|
+
assert.match(out, /page 2 of/);
|
|
145
|
+
assert.match(out, /Other Commands:/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await check('empty filtered Plugin table', () => assert.match(xy(['plugins', eventID, '--enabled', 'true']), /No filtered plugins found/i));
|
|
149
|
+
|
|
150
|
+
await check('Event Plugin detail covers type-specific settings and parameters', () => {
|
|
151
|
+
const out = xy(['plugin', eventID]);
|
|
152
|
+
fs.writeFileSync(Path.join(temp, 'event-detail.txt'), out);
|
|
153
|
+
for (const label of ['Plugin Summary', 'Command', 'Script', 'Abort Policy', 'Remote Runner', 'Plugin Notes', 'Event notes', 'Plugin Parameters', 'Sample Tool', 'Message', 'Alternate Tool', 'Alternate Count', 'Revision', 'Plugin Script', '(Shown in verbose mode)']) assert.ok(out.toLowerCase().includes(label.toLowerCase()), label);
|
|
154
|
+
assert.ok(!out.includes(script.trim()), 'Normal detail does not print full script');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await check('verbose detail prints highlighted embedded script', () => {
|
|
158
|
+
const out = xy(['plugin', eventID, '--verbose']);
|
|
159
|
+
assert.match(out, /PLUGIN SCRIPT/);
|
|
160
|
+
assert.match(out, /console\.log\(JSON\.stringify/);
|
|
161
|
+
assert.doesNotMatch(out, /\(Shown in verbose mode\)/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await check('executable language detection mirrors xyOps', () => {
|
|
165
|
+
assert.equal(require('../lib/utils.js').getLangFromBinary('/usr/bin/node --use-strict'), 'javascript');
|
|
166
|
+
assert.equal(require('../lib/utils.js').getLangFromBinary('/usr/bin/python3 -u'), 'python');
|
|
167
|
+
assert.equal(require('../lib/utils.js').getLangFromBinary('/bin/bash'), 'shell');
|
|
168
|
+
assert.equal(require('../lib/utils.js').getLangFromBinary('pwsh -File'), 'powershell');
|
|
169
|
+
assert.equal(require('../lib/utils.js').getLangFromBinary('unknown-binary'), null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await check('Monitor Plugin detail covers monitor settings', () => {
|
|
173
|
+
const out = xy(['plugin', monitorID]);
|
|
174
|
+
for (const label of ['Groups', 'Format', 'Quick Monitor']) assert.ok(out.includes(label), label);
|
|
175
|
+
assert.ok(!out.includes('PLUGIN PARAMETERS'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await check('human update shows target and parsed data', () => {
|
|
179
|
+
const out = xy(['plugin', 'update', eventID, '--notes', 'Preview', '--dry']);
|
|
180
|
+
assert.match(out, /UPDATE PLUGIN/);
|
|
181
|
+
assert.match(out, new RegExp('Plugin ID:\\s+' + eventID));
|
|
182
|
+
assert.match(out, /UPDATE DATA[\s\S]*"notes": "Preview"/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await check('sparse dry update excludes unrelated fields', () => {
|
|
186
|
+
assert.deepEqual(json(['plugin', 'update', eventID, '--notes', 'Preview', '--dry']), { notes: 'Preview', id: eventID });
|
|
187
|
+
assert.deepEqual(json(['plugin', 'update', monitorID, '--notes', 'Preview', '--dry']), { notes: 'Preview', id: monitorID });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await check('dry update leaves revisions unchanged', () => {
|
|
191
|
+
assert.equal(json(['plugin', eventID]).revision, 1);
|
|
192
|
+
assert.equal(json(['plugin', monitorID]).revision, 1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
json([
|
|
196
|
+
'plugin', 'update', eventID,
|
|
197
|
+
'--params.0.title', 'Display Name', '--params.0.required', 'false',
|
|
198
|
+
'--params.1.value', 'true',
|
|
199
|
+
'--param', '{"id":"count","title":"Count","type":"text","variant":"number","value":3}'
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
await check('indexed parameter edits and append preserve other settings', () => {
|
|
203
|
+
const row = json(['plugin', eventID]);
|
|
204
|
+
assert.deepEqual(row.params.map(param => param.id), ['name', 'loud', 'tool', 'count']);
|
|
205
|
+
assert.equal(row.params[0].title, 'Display Name');
|
|
206
|
+
assert.equal(row.params[0].required, false);
|
|
207
|
+
assert.equal(row.params[1].value, true);
|
|
208
|
+
assert.equal(row.command, 'node');
|
|
209
|
+
assert.equal(row.kill, 'all');
|
|
210
|
+
assert.equal(row.revision, 2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
json([
|
|
214
|
+
'plugin', 'update', eventID,
|
|
215
|
+
'--params', '[{"id":"first","title":"First","type":"text","value":"one"}]',
|
|
216
|
+
'--params.0.value', 'changed',
|
|
217
|
+
'--param', '{"id":"second","title":"Second","type":"checkbox","value":false}'
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
await check('parameter replacement precedes indexed edits and appends', () => {
|
|
221
|
+
const params = json(['plugin', eventID]).params;
|
|
222
|
+
assert.deepEqual(params.map(param => param.id), ['first', 'second']);
|
|
223
|
+
assert.equal(params[0].value, 'changed');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
json(['plugin', 'update', eventID, '--params', '@-'], {
|
|
227
|
+
input: JSON.stringify([{ id: 'stdin', title: 'From stdin', type: 'code', value: 'return true;' }])
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await check('complete parameter list from stdin', () => {
|
|
231
|
+
const row = json(['plugin', eventID]);
|
|
232
|
+
assert.equal(row.params.length, 1);
|
|
233
|
+
assert.equal(row.params[0].id, 'stdin');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
json(['plugin', 'update', monitorID, '--json', '@-'], {
|
|
237
|
+
input: JSON.stringify({ format: 'text' })
|
|
238
|
+
});
|
|
239
|
+
await check('complete JSON request accepts native Monitor Plugin format', () => assert.equal(json(['plugin', monitorID]).format, 'text'));
|
|
240
|
+
|
|
241
|
+
json(['plugin', 'update', monitorID, '--groups', '[]', '--group', group.id, '--group', group.id, '--plugin_format', 'xml', '--quick', 'false']);
|
|
242
|
+
|
|
243
|
+
await check('Monitor Plugin list replacement and appends are deterministic', () => {
|
|
244
|
+
const row = json(['plugin', monitorID]);
|
|
245
|
+
assert.deepEqual(row.groups, [group.id]);
|
|
246
|
+
assert.equal(row.format, 'xml');
|
|
247
|
+
assert.equal(row.quick, false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
json(['plugin', 'update', monitorID, '--groups.0', group.id]);
|
|
251
|
+
await check('Monitor Plugin indexed group update', () => assert.deepEqual(json(['plugin', monitorID]).groups, [group.id]));
|
|
252
|
+
|
|
253
|
+
const eventRevision = json(['plugin', eventID]).revision;
|
|
254
|
+
const monitorRevision = json(['plugin', monitorID]).revision;
|
|
255
|
+
await check('unconfirmed delete shows target and warning toast', () => {
|
|
256
|
+
for (const args of [['delete', eventID], ['delete', eventID, '--confirm', 'false']]) {
|
|
257
|
+
const out = xy(['plugin', ...args]);
|
|
258
|
+
assert.match(out, /DELETE PLUGIN/);
|
|
259
|
+
assert.match(out, /⚠️[\s\S]*Please confirm the Plugin delete/);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const rejected = [
|
|
264
|
+
['update', eventID, '--type', 'action'],
|
|
265
|
+
['update', eventID, '--params.99.title', 'Bad'],
|
|
266
|
+
['update', eventID, '--params.__proto__.x', 'Bad'],
|
|
267
|
+
['update', eventID, '--params', '{}'],
|
|
268
|
+
['update', eventID, '--params', '[5]'],
|
|
269
|
+
['update', eventID, '--param', '{"id":"bad","title":"Bad","type":"bogus","value":"x"}'],
|
|
270
|
+
['update', eventID, '--params', '[{"id":"same","title":"One","type":"text","value":"a"},{"id":"same","title":"Two","type":"text","value":"b"}]'],
|
|
271
|
+
['update', eventID, '--enabled', 'perhaps'],
|
|
272
|
+
['update', eventID, '--kill', 'children'],
|
|
273
|
+
['update', eventID, '--group', group.id],
|
|
274
|
+
['update', actionID, '--runner', 'true'],
|
|
275
|
+
['update', monitorID, '--param', '{"id":"bad","title":"Bad","type":"text","value":"x"}'],
|
|
276
|
+
['update', monitorID, '--kill', 'all'],
|
|
277
|
+
['update', monitorID, '--plugin_format', 'yaml'],
|
|
278
|
+
['update', eventID, '--revision', '99'],
|
|
279
|
+
['update', eventID],
|
|
280
|
+
['update', title + ' Event', '--notes', 'Wrong selector'],
|
|
281
|
+
['update', eventID, '--id', actionID, '--notes', 'Wrong ID'],
|
|
282
|
+
['delete', title + ' Event', '--confirm']
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
for (const args of rejected) {
|
|
286
|
+
await check('reject ' + args.slice(0, 4).join(' '), () => xy(['plugin', ...args], { fail: true }));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await check('rejected requests leave revisions unchanged', () => {
|
|
290
|
+
assert.equal(json(['plugin', eventID]).revision, eventRevision);
|
|
291
|
+
assert.equal(json(['plugin', monitorID]).revision, monitorRevision);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await check('invalid Plugin type is rejected', () => xy(['plugin', 'create', '--title', title, '--type', 'service', '--command', 'node'], { fail: true }));
|
|
295
|
+
|
|
296
|
+
await check('missing command is rejected', () => xy(['plugin', 'create', '--title', title], { fail: true }));
|
|
297
|
+
|
|
298
|
+
await check('duplicate Plugin ID error is surfaced', () => assert.match(xy(['plugin', 'create', '--id', eventID, '--title', title, '--command', 'node'], { fail: true }), /already exists/i));
|
|
299
|
+
|
|
300
|
+
const auto = json(['plugin', 'create', '--title', title + ' Auto', '--type', 'action', '--command', 'node']);
|
|
301
|
+
ids.add(auto.id);
|
|
302
|
+
await check('automatic ID and Action Plugin defaults', () => {
|
|
303
|
+
assert.match(auto.id, /^p/);
|
|
304
|
+
assert.equal(auto.type, 'action');
|
|
305
|
+
assert.equal(auto.enabled, true);
|
|
306
|
+
assert.deepEqual(auto.params, []);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const humanID = prefix + '_human';
|
|
310
|
+
ids.add(humanID);
|
|
311
|
+
await check('human creation suggestions', () => assert.match(xy(['plugin', 'create', '--id', humanID, '--title', title + ' Human', '--type', 'scheduler', '--command', 'node']), /Other Commands:/));
|
|
312
|
+
|
|
313
|
+
// Remove auxiliary fixtures before final deletion checks.
|
|
314
|
+
for (const id of Array.from(ids)) {
|
|
315
|
+
if ([eventID, monitorID, actionID, schedulerID].includes(id)) continue;
|
|
316
|
+
json(['plugin', 'delete', id, '--confirm']);
|
|
317
|
+
ids.delete(id);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await check('dry delete request', () => assert.deepEqual(json(['plugin', 'delete', eventID, '--confirm', '--dry']), { id: eventID }));
|
|
321
|
+
|
|
322
|
+
await check('dry delete leaves Plugin', () => assert.equal(json(['plugin', eventID]).id, eventID));
|
|
323
|
+
|
|
324
|
+
for (const id of [eventID, monitorID, actionID, schedulerID]) {
|
|
325
|
+
json(['plugin', 'delete', id, '--confirm']);
|
|
326
|
+
ids.delete(id);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await check('delete removes all four Plugin types', () => assert.deepEqual(json(['plugins', title]), []));
|
|
330
|
+
|
|
331
|
+
await check('get after deletion fails', () => xy(['plugin', eventID], { fail: true }));
|
|
332
|
+
|
|
333
|
+
for (const topic of ['plugins', 'plugin', 'plugin list', 'plugin get', 'plugin create', 'plugin update', 'plugin delete']) {
|
|
334
|
+
await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await check('existing Plugins remain unchanged', async () => {
|
|
338
|
+
const now = (await apiCall('getPlugins')).rows;
|
|
339
|
+
for (const plugin of original) {
|
|
340
|
+
const current = now.find(item => item.id === plugin.id);
|
|
341
|
+
assert.ok(current, 'Existing Plugin still present: ' + plugin.id);
|
|
342
|
+
assert.equal(JSON.stringify(current), JSON.stringify(plugin), 'Existing Plugin unchanged: ' + plugin.id);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
await cleanupFixtures([
|
|
348
|
+
{ list: 'plugins', method: 'deletePlugin', match: item => ids.has(item.id) || item.title.startsWith(title) }
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const Path = require('node:path');
|
|
6
|
+
const { loadTestConfig, createCheck, createTempDir, xy, json, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
|
|
7
|
+
|
|
8
|
+
test('secrets', async t => {
|
|
9
|
+
loadTestConfig();
|
|
10
|
+
const check = createCheck(t);
|
|
11
|
+
|
|
12
|
+
// These values are disposable test data, but still keep plaintext out of
|
|
13
|
+
// assertion details and API diagnostics to exercise the CLI's safety model.
|
|
14
|
+
const stamp = Date.now();
|
|
15
|
+
const id = 'cli_secret_test_' + stamp;
|
|
16
|
+
const title = 'CLI Secret Test ' + stamp;
|
|
17
|
+
const temp = createTempDir(t, 'xycli-secret-test-');
|
|
18
|
+
const initialFields = [
|
|
19
|
+
{ name: 'CLI_TEST_TOKEN', value: 'token-' + stamp },
|
|
20
|
+
{ name: 'CLI_TEST_MULTILINE', value: 'first-' + stamp + '\nsecond line\n indented line' }
|
|
21
|
+
];
|
|
22
|
+
const replacementFields = [
|
|
23
|
+
{ name: 'CLI_TEST_REPLACED', value: 'replacement-' + stamp + '\nlast line' }
|
|
24
|
+
];
|
|
25
|
+
const digest = value => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
26
|
+
const initialDigest = digest(initialFields);
|
|
27
|
+
const replacementDigest = digest(replacementFields);
|
|
28
|
+
const initialFile = Path.join(temp, 'initial-fields.json');
|
|
29
|
+
fs.writeFileSync(initialFile, JSON.stringify(initialFields));
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const original = (await apiCall('getSecrets')).rows;
|
|
33
|
+
const multi = await apiCall('getMultiple', { lists: 'all' });
|
|
34
|
+
const event = multi.events[0];
|
|
35
|
+
const category = multi.categories[0];
|
|
36
|
+
const plugin = multi.plugins[0];
|
|
37
|
+
const webHook = multi.web_hooks[0];
|
|
38
|
+
assert.ok(event, 'Event required for Secret Vault assignment tests');
|
|
39
|
+
assert.ok(category, 'Category required for Secret Vault assignment tests');
|
|
40
|
+
assert.ok(plugin, 'Plugin required for Secret Vault assignment tests');
|
|
41
|
+
assert.ok(webHook, 'Web Hook required for Secret Vault assignment tests');
|
|
42
|
+
|
|
43
|
+
await check('dry creation redacts fields and fills defaults', () => {
|
|
44
|
+
const req = json([
|
|
45
|
+
'secret', 'create', '--id', id, '--title', title,
|
|
46
|
+
'--fields', '@' + initialFile, '--dry'
|
|
47
|
+
]);
|
|
48
|
+
assert.equal(req.id, id);
|
|
49
|
+
assert.equal(req.enabled, true);
|
|
50
|
+
assert.deepEqual(req.events, []);
|
|
51
|
+
assert.deepEqual(req.categories, []);
|
|
52
|
+
assert.deepEqual(req.plugins, []);
|
|
53
|
+
assert.deepEqual(req.web_hooks, []);
|
|
54
|
+
assert.deepEqual(req.fields, initialFields.map(field => ({ name: field.name, value: '[REDACTED]' })));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await check('dry creation never prints plaintext', () => {
|
|
58
|
+
const out = xy(['secret', 'create', '--id', id, '--title', title, '--fields', '@' + initialFile, '--dry', '--verbose']);
|
|
59
|
+
assert.match(out, /\[REDACTED\]/);
|
|
60
|
+
for (const field of initialFields) assert.ok(!out.includes(field.value), 'Plaintext is absent from dry output');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await check('dry creation does not persist', () => assert.deepEqual(json(['secrets', '--id', id]), []));
|
|
64
|
+
|
|
65
|
+
const created = json([
|
|
66
|
+
'secret', 'create', '--id', id, '--title', title,
|
|
67
|
+
'--fields', '@' + initialFile,
|
|
68
|
+
'--events', event.id + ',' + event.id,
|
|
69
|
+
'--category', category.id,
|
|
70
|
+
'--plugin', plugin.id,
|
|
71
|
+
'--hook', webHook.id,
|
|
72
|
+
'--notes', 'Vault notes\nSecond line'
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
await check('create persists safe metadata and every assignment type', () => {
|
|
76
|
+
assert.equal(created.id, id);
|
|
77
|
+
assert.equal(created.title, title);
|
|
78
|
+
assert.equal(created.enabled, true);
|
|
79
|
+
assert.deepEqual(created.names, initialFields.map(field => field.name));
|
|
80
|
+
assert.deepEqual(created.events, [event.id]);
|
|
81
|
+
assert.deepEqual(created.categories, [category.id]);
|
|
82
|
+
assert.deepEqual(created.plugins, [plugin.id]);
|
|
83
|
+
assert.deepEqual(created.web_hooks, [webHook.id]);
|
|
84
|
+
assert.ok(!('fields' in created), 'Create response omits plaintext fields');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await check('stored encrypted data matches file input', async () => {
|
|
88
|
+
const data = await apiCall('decryptSecret', { id: id });
|
|
89
|
+
assert.equal(digest(data.fields), initialDigest);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await check('exact and fuzzy metadata get never expose values', () => {
|
|
93
|
+
const exact = json(['secret', id]);
|
|
94
|
+
const fuzzy = json(['secret', 'get', '--title', title.toLowerCase()]);
|
|
95
|
+
assert.equal(exact.id, id);
|
|
96
|
+
assert.equal(fuzzy.id, id);
|
|
97
|
+
assert.ok(!('fields' in exact));
|
|
98
|
+
assert.ok(!('fields' in fuzzy));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await check('human get shows names, assignments, and multiline notes only', () => {
|
|
102
|
+
const out = xy(['secret', id]);
|
|
103
|
+
for (const label of ['Secret Vault Summary', 'Variable Names', 'Events', 'Categories', 'Plugins', 'Web Hooks', 'Secret Vault Notes', 'Second line']) {
|
|
104
|
+
assert.ok(out.toLowerCase().includes(label.toLowerCase()), label);
|
|
105
|
+
}
|
|
106
|
+
for (const field of initialFields) assert.ok(!out.includes(field.value), 'Plaintext is absent from metadata view');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await check('list search and assignment filters return safe metadata', () => {
|
|
110
|
+
const searches = [
|
|
111
|
+
['secrets', title],
|
|
112
|
+
['secret', 'list', '--name', initialFields[0].name],
|
|
113
|
+
['secrets', '--event', event.id, '--category', category.id],
|
|
114
|
+
['secrets', '--plugin', plugin.id, '--hook', webHook.id],
|
|
115
|
+
['secrets', '--web_hook', webHook.id]
|
|
116
|
+
];
|
|
117
|
+
for (const args of searches) {
|
|
118
|
+
const row = json(args).find(item => item.id === id);
|
|
119
|
+
assert.ok(row, 'Secret Vault found by list filter');
|
|
120
|
+
assert.ok(!('fields' in row));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await check('human list supports pagination and suggestions', () => {
|
|
125
|
+
const out = xy(['secrets', '--limit', '1', '--page', '1']);
|
|
126
|
+
assert.match(out, /page 1 of/i);
|
|
127
|
+
assert.match(out, /Other Commands:/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await check('human update shows target and redacted parsed data', () => {
|
|
131
|
+
const out = xy(['secret', 'update', id, '--fields', '@' + initialFile, '--dry']);
|
|
132
|
+
assert.match(out, /UPDATE SECRET VAULT/);
|
|
133
|
+
assert.match(out, new RegExp('Vault ID:\\s+' + id));
|
|
134
|
+
assert.match(out, /UPDATE DATA[\s\S]*\[REDACTED\]/);
|
|
135
|
+
for (const field of initialFields) assert.ok(!out.includes(field.value), 'Plaintext is absent from update preview');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await check('sparse metadata dry update excludes unrelated data', () => {
|
|
139
|
+
assert.deepEqual(json(['secret', 'update', id, '--enabled', 'false', '--dry']), { enabled: false, id: id });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
json(['secret', 'update', id, '--enabled', 'false', '--notes', 'Updated vault notes']);
|
|
143
|
+
|
|
144
|
+
await check('metadata update preserves encrypted variables', async () => {
|
|
145
|
+
const metadata = json(['secret', id]);
|
|
146
|
+
const data = await apiCall('decryptSecret', { id: id });
|
|
147
|
+
assert.equal(metadata.enabled, false);
|
|
148
|
+
assert.equal(metadata.notes, 'Updated vault notes');
|
|
149
|
+
assert.equal(digest(data.fields), initialDigest);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await check('stdin replacement dry run redacts every value', () => {
|
|
153
|
+
const req = json(['secret', 'update', id, '--fields', '@-', '--dry'], {
|
|
154
|
+
input: JSON.stringify(replacementFields)
|
|
155
|
+
});
|
|
156
|
+
assert.deepEqual(req, {
|
|
157
|
+
fields: replacementFields.map(field => ({ name: field.name, value: '[REDACTED]' })),
|
|
158
|
+
id: id
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
json(['secret', 'update', id, '--fields', '@-'], {
|
|
163
|
+
input: JSON.stringify(replacementFields)
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await check('complete fields array replacement from stdin', async () => {
|
|
167
|
+
const metadata = json(['secret', id]);
|
|
168
|
+
const data = await apiCall('decryptSecret', { id: id });
|
|
169
|
+
assert.deepEqual(metadata.names, replacementFields.map(field => field.name));
|
|
170
|
+
assert.equal(digest(data.fields), replacementDigest);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await check('unconfirmed decrypt shows target and warning without plaintext', () => {
|
|
174
|
+
for (const args of [['decrypt', id], ['decrypt', id, '--confirm', 'false']]) {
|
|
175
|
+
const out = xy(['secret', ...args]);
|
|
176
|
+
assert.match(out, /DECRYPT SECRET VAULT/);
|
|
177
|
+
assert.match(out, /⚠️[\s\S]*confirm decryption/i);
|
|
178
|
+
for (const field of replacementFields) assert.ok(!out.includes(field.value), 'Plaintext is absent without confirmation');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await check('confirmed human decrypt preserves multiline plaintext sections', () => {
|
|
183
|
+
const out = xy(['secret', 'decrypt', id, '--confirm']);
|
|
184
|
+
assert.match(out, new RegExp('SECRET VARIABLE: ' + replacementFields[0].name));
|
|
185
|
+
assert.ok(out.includes(replacementFields[0].value), 'Exact multiline value is present');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await check('confirmed JSON decrypt returns the plaintext fields array', () => {
|
|
189
|
+
const fields = json(['secret', 'decrypt', id, '--confirm']);
|
|
190
|
+
assert.equal(digest(fields), replacementDigest);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await check('verbose decrypt diagnostics stay redacted', () => {
|
|
194
|
+
const out = xy(['secret', 'decrypt', id, '--confirm', '--verbose']);
|
|
195
|
+
assert.match(out, /API RESPONSE:[\s\S]*\[REDACTED\]/);
|
|
196
|
+
assert.ok(out.includes(replacementFields[0].value), 'Final confirmed output contains plaintext');
|
|
197
|
+
const response = out.slice(out.indexOf('API RESPONSE:'), out.indexOf('SECRET VARIABLE:'));
|
|
198
|
+
assert.ok(!response.includes(replacementFields[0].value), 'Diagnostic response omits plaintext');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const rejected = [
|
|
202
|
+
['update', id],
|
|
203
|
+
['update', title, '--notes', 'Wrong selector'],
|
|
204
|
+
['update', id, '--field', '{"name":"BAD","value":"hidden"}'],
|
|
205
|
+
['update', id, '--fields.0.value', 'hidden'],
|
|
206
|
+
['update', id, '--fields', '{}'],
|
|
207
|
+
['update', id, '--fields', '[{"name":"BAD-NAME","value":"hidden"}]'],
|
|
208
|
+
['update', id, '--fields', '[{"name":"SAME","value":"one"},{"name":"SAME","value":"two"}]'],
|
|
209
|
+
['update', id, '--fields', '[{"name":"VALID","value":5}]'],
|
|
210
|
+
['update', id, '--event', 'missing_secret_assignment'],
|
|
211
|
+
['update', id, '--revision', '99'],
|
|
212
|
+
['update', id, '--enabled', 'perhaps'],
|
|
213
|
+
['decrypt', title, '--confirm'],
|
|
214
|
+
['decrypt', id, '--confirm', 'maybe'],
|
|
215
|
+
['delete', title, '--confirm'],
|
|
216
|
+
['delete', id, '--confirm', 'maybe']
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
for (const args of rejected) {
|
|
220
|
+
await check('reject secret ' + args.slice(0, 4).join(' '), () => xy(['secret', ...args], { fail: true }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await check('invalid non-array fields are rejected without echoing plaintext', () => {
|
|
224
|
+
const out = xy(['secret', 'update', id, '--fields', '{"password":"must-not-print"}'], { fail: true });
|
|
225
|
+
assert.ok(!out.includes('must-not-print'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await check('unsupported field alias is rejected without echoing its value', () => {
|
|
229
|
+
const out = xy(['secret', 'update', id, '--field', '{"name":"TOKEN","value":"must-not-print-either"}'], { fail: true });
|
|
230
|
+
assert.ok(!out.includes('must-not-print-either'));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await check('unconfirmed delete shows target and warning', () => {
|
|
234
|
+
for (const args of [['delete', id], ['delete', id, '--confirm', 'false']]) {
|
|
235
|
+
const out = xy(['secret', ...args]);
|
|
236
|
+
assert.match(out, /DELETE SECRET VAULT/);
|
|
237
|
+
assert.match(out, /⚠️[\s\S]*confirm the Secret Vault delete/i);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await check('dry delete request contains only the ID', () => {
|
|
242
|
+
assert.deepEqual(json(['secret', 'delete', id, '--confirm', '--dry']), { id: id });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await check('dry delete leaves the vault intact', () => assert.equal(json(['secret', id]).id, id));
|
|
246
|
+
|
|
247
|
+
json(['secret', 'delete', id, '--confirm']);
|
|
248
|
+
|
|
249
|
+
await check('confirmed delete removes the vault', () => assert.deepEqual(json(['secrets', '--id', id]), []));
|
|
250
|
+
|
|
251
|
+
await check('get after deletion fails', () => xy(['secret', id], { fail: true }));
|
|
252
|
+
|
|
253
|
+
for (const topic of ['secrets', 'secret', 'secret list', 'secret get', 'secret create', 'secret update', 'secret decrypt', 'secret delete']) {
|
|
254
|
+
await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await check('existing Secret Vault metadata remains unchanged', async () => {
|
|
258
|
+
const now = (await apiCall('getSecrets')).rows;
|
|
259
|
+
for (const secret of original) {
|
|
260
|
+
const current = now.find(item => item.id === secret.id);
|
|
261
|
+
assert.ok(current, 'Existing Secret Vault still present: ' + secret.id);
|
|
262
|
+
assert.equal(JSON.stringify(current), JSON.stringify(secret), 'Existing Secret Vault unchanged: ' + secret.id);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await cleanupFixtures([
|
|
268
|
+
{ list: 'secrets', method: 'deleteSecret', match: item => item.id === id || item.title === title }
|
|
269
|
+
]);
|
|
270
|
+
}
|
|
271
|
+
});
|