unbound-cli 1.20.2 → 1.20.4
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/package.json +1 -1
- package/src/commands/pack.js +92 -40
- package/src/lib/pack-remote.js +32 -0
- package/src/toolHealth.js +17 -2
- package/test/pack-cli-integration.test.js +312 -0
- package/test/pack-confirm.test.js +30 -1
- package/test/pack-remote.test.js +42 -0
- package/test/tool-health.test.js +25 -0
package/package.json
CHANGED
package/src/commands/pack.js
CHANGED
|
@@ -124,7 +124,7 @@ const ACTION_LABEL = {
|
|
|
124
124
|
conflict: 'CONFLICT',
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
-
function printPlan({ file, pack, ops, live }) {
|
|
127
|
+
function printPlan({ file, pack, ops, live, versionState }) {
|
|
128
128
|
console.log('');
|
|
129
129
|
output.info(`Pack ${pack.name} v${pack.version} (${file})`);
|
|
130
130
|
output.table(ops, [
|
|
@@ -143,6 +143,9 @@ function printPlan({ file, pack, ops, live }) {
|
|
|
143
143
|
output.warn(`${off.length} polic${many ? 'ies are' : 'y is'} off on the server but on in the pack (switched off in the console, or just enabled in the file). Apply turns ${many ? 'them' : 'it'} on; to keep ${many ? 'one' : 'it'} off, set enabled: false in the pack file.`);
|
|
144
144
|
}
|
|
145
145
|
const s = summarize(ops);
|
|
146
|
+
if (versionState.supported && versionState.version !== pack.version) {
|
|
147
|
+
output.info(`Stored version: ${versionState.version || 'none'} -> ${pack.version}`);
|
|
148
|
+
}
|
|
146
149
|
const parts = [];
|
|
147
150
|
if (s.create) parts.push(`${s.create} to create`);
|
|
148
151
|
if (s.update) parts.push(`${s.update} to update`);
|
|
@@ -154,12 +157,14 @@ function printPlan({ file, pack, ops, live }) {
|
|
|
154
157
|
console.log(` ${parts.join(', ')}`);
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
function planJson({ file, pack, ops }) {
|
|
160
|
+
function planJson({ file, pack, ops, versionState }) {
|
|
161
|
+
const summary = summarize(ops);
|
|
162
|
+
const versionOnly = versionState.supported && versionState.version !== pack.version && !summary.drift;
|
|
158
163
|
return {
|
|
159
164
|
pack: pack.name,
|
|
160
165
|
version: pack.version,
|
|
161
166
|
file,
|
|
162
|
-
summary:
|
|
167
|
+
summary: { ...summary, drift: summary.drift + (versionOnly ? 1 : 0) },
|
|
163
168
|
ops: ops.map((op) => ({
|
|
164
169
|
op: op.op,
|
|
165
170
|
kind: op.kind,
|
|
@@ -179,10 +184,21 @@ async function planPack(pack, { prune, names }) {
|
|
|
179
184
|
throw new PackError(`could not load the MCP server catalogue (${ref.mcpServersError}); this pack uses mcp.actionType, which needs it. Retry, or list mcp.tools explicitly`);
|
|
180
185
|
}
|
|
181
186
|
const desired = toDesired(pack, ref);
|
|
187
|
+
const versionState = await remote.fetchVersion(pack.name);
|
|
182
188
|
const live = await remote.fetchManaged(pack.name);
|
|
183
189
|
live.names = names || await remote.fetchAllNames();
|
|
184
190
|
const ops = buildPlan(desired, live, { prune });
|
|
185
|
-
return { ops, live, ref };
|
|
191
|
+
return { ops, live, ref, versionState };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function fetchStablePack(name) {
|
|
195
|
+
const before = await remote.fetchVersion(name);
|
|
196
|
+
const live = await remote.fetchManaged(name);
|
|
197
|
+
const after = await remote.fetchVersion(name);
|
|
198
|
+
if (before.supported !== after.supported || before.version !== after.version) {
|
|
199
|
+
throw new PackError(`pack "${name}" changed during export; retry`);
|
|
200
|
+
}
|
|
201
|
+
return { live, version: after.version };
|
|
186
202
|
}
|
|
187
203
|
|
|
188
204
|
/**
|
|
@@ -207,13 +223,14 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
|
|
|
207
223
|
const names = await remote.fetchAllNames();
|
|
208
224
|
const planned = [];
|
|
209
225
|
for (const { file, pack } of packs) {
|
|
210
|
-
const { ops, live } = await planPack(pack, { prune, names });
|
|
211
|
-
planned.push({ file, pack, ops, live });
|
|
226
|
+
const { ops, live, versionState } = await planPack(pack, { prune, names });
|
|
227
|
+
planned.push({ file, pack, ops, live, versionState });
|
|
212
228
|
}
|
|
213
229
|
|
|
214
230
|
const totals = planned.reduce((acc, p) => {
|
|
215
231
|
const s = summarize(p.ops);
|
|
216
232
|
for (const k of Object.keys(s)) acc[k] = (acc[k] || 0) + s[k];
|
|
233
|
+
if (p.versionState.supported && p.versionState.version !== p.pack.version && !s.drift) acc.drift += 1;
|
|
217
234
|
return acc;
|
|
218
235
|
}, {});
|
|
219
236
|
|
|
@@ -231,12 +248,6 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
|
|
|
231
248
|
return { drift: totals.drift, failed: 0 };
|
|
232
249
|
}
|
|
233
250
|
|
|
234
|
-
if (!totals.drift) {
|
|
235
|
-
if (json) output.json({ mode: label, dryRun: false, packs: planned.map(planJson), results: [] });
|
|
236
|
-
else output.success('Already in sync.');
|
|
237
|
-
return { drift: 0, failed: 0 };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
251
|
const destructive = totals.delete + totals.replace;
|
|
241
252
|
if (destructive && !yes) {
|
|
242
253
|
const ok = await confirm(`This will delete ${destructive} polic${destructive > 1 ? 'ies' : 'y'} (${totals.delete} delete, ${totals.replace} replace). Continue?`);
|
|
@@ -249,6 +260,22 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
|
|
|
249
260
|
const results = [];
|
|
250
261
|
for (const p of planned) {
|
|
251
262
|
const todo = p.ops.filter((op) => ['create', 'update', 'replace', 'delete'].includes(op.op));
|
|
263
|
+
if (!todo.length && (!p.versionState.supported || p.versionState.version === p.pack.version)) continue;
|
|
264
|
+
if (p.versionState.supported) {
|
|
265
|
+
try {
|
|
266
|
+
await remote.applyPlan(p.pack.name, p.pack.version, todo, {
|
|
267
|
+
expectedVersion: p.versionState.version,
|
|
268
|
+
});
|
|
269
|
+
results.push(...(todo.length ? todo.map((op) => ({ pack: p.pack.name, policy: op.serverName, op: op.op, status: 'ok', error: null }))
|
|
270
|
+
: [{ pack: p.pack.name, policy: null, op: 'version', status: 'ok', error: null }]));
|
|
271
|
+
} catch (err) {
|
|
272
|
+
const message = err && err.message ? err.message : String(err);
|
|
273
|
+
results.push({ pack: p.pack.name, policy: null, op: 'apply', status: 'failed', error: message });
|
|
274
|
+
if (!json) output.error(`Pack ${p.pack.name}: ${message}`);
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
output.warn('Older backend: applying policies one at a time; this run is not atomic.');
|
|
252
279
|
const packResults = await remote.writeOps(todo, {
|
|
253
280
|
onResult: (r) => {
|
|
254
281
|
if (json) return;
|
|
@@ -264,7 +291,8 @@ async function runApply(target, { dryRun = false, prune = false, yes = false, js
|
|
|
264
291
|
output.json({ mode: label, dryRun: false, packs: planned.map(planJson), results });
|
|
265
292
|
} else {
|
|
266
293
|
console.log('');
|
|
267
|
-
if (failed) output.warn(`${results.length - failed} succeeded, ${failed} failed. Re-run to retry
|
|
294
|
+
if (failed) output.warn(`${results.length - failed} change(s) succeeded, ${failed} pack or policy write(s) failed. Re-run to retry.`);
|
|
295
|
+
else if (!results.length) output.success('Already in sync.');
|
|
268
296
|
else output.success(`${results.length} change${results.length > 1 ? 's' : ''} applied.`);
|
|
269
297
|
}
|
|
270
298
|
return { drift: totals.drift, failed };
|
|
@@ -384,9 +412,8 @@ Examples:
|
|
|
384
412
|
$ unbound pack sync policies/ --dry-run
|
|
385
413
|
$ unbound pack sync policies/ --yes
|
|
386
414
|
|
|
387
|
-
A pack whose file is removed from the folder keeps its policies
|
|
388
|
-
|
|
389
|
-
to remove a whole pack.
|
|
415
|
+
A pack whose file is removed from the folder keeps its policies. Use
|
|
416
|
+
\`unbound pack delete <pack> --pack-version <new-version>\` to remove a whole pack.
|
|
390
417
|
`)
|
|
391
418
|
.action(async (target, opts) => {
|
|
392
419
|
try {
|
|
@@ -402,7 +429,7 @@ to remove a whole pack.
|
|
|
402
429
|
.description('Write the live policies of a pack as a pack file.')
|
|
403
430
|
.option('-o, --out <path>', 'File to write (default: stdout). With --all: a folder, one file per pack')
|
|
404
431
|
.option('--all', 'Export every pack found in the org')
|
|
405
|
-
.option('--pack-version <version>', '
|
|
432
|
+
.option('--pack-version <version>', 'Override the stored metadata.version')
|
|
406
433
|
.option('--json', 'Write JSON instead of YAML')
|
|
407
434
|
.addHelpText('after', `
|
|
408
435
|
Examples:
|
|
@@ -426,13 +453,14 @@ Examples:
|
|
|
426
453
|
output.info('No pack-managed policies found in this org.');
|
|
427
454
|
}
|
|
428
455
|
fs.mkdirSync(opts.out, { recursive: true });
|
|
429
|
-
for (const
|
|
456
|
+
for (const name of [...packs.keys()].sort((a, b) => a.localeCompare(b))) {
|
|
457
|
+
const { live, version } = await fetchStablePack(name);
|
|
430
458
|
for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, not exported.`);
|
|
431
459
|
if (!live.tool.length && !live.generic.length) {
|
|
432
460
|
output.warn(`pack "${name}": only unsupported policies, no file written.`);
|
|
433
461
|
continue;
|
|
434
462
|
}
|
|
435
|
-
const doc = liveToPack(name, live, ref, { version: opts.packVersion });
|
|
463
|
+
const doc = liveToPack(name, live, ref, { version: opts.packVersion || version || '1.0.0' });
|
|
436
464
|
writeOrPrint(serialize(doc), path.join(opts.out, `${name}${ext}`));
|
|
437
465
|
}
|
|
438
466
|
if (unmanaged.length) {
|
|
@@ -441,14 +469,14 @@ Examples:
|
|
|
441
469
|
return;
|
|
442
470
|
}
|
|
443
471
|
|
|
444
|
-
const live = await
|
|
472
|
+
const { live, version } = await fetchStablePack(packName);
|
|
445
473
|
for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, not exported.`);
|
|
446
474
|
if (!live.tool.length && !live.generic.length) {
|
|
447
475
|
throw new PackError(live.unsupported.length
|
|
448
476
|
? `pack "${packName}" only has policies of types this CLI version does not manage; nothing to export`
|
|
449
477
|
: `pack "${packName}" manages no policies in this org`);
|
|
450
478
|
}
|
|
451
|
-
const doc = liveToPack(packName, live, ref, { version: opts.packVersion });
|
|
479
|
+
const doc = liveToPack(packName, live, ref, { version: opts.packVersion || version || '1.0.0' });
|
|
452
480
|
writeOrPrint(serialize(doc), opts.out);
|
|
453
481
|
} catch (err) {
|
|
454
482
|
fail(err);
|
|
@@ -462,7 +490,7 @@ Examples:
|
|
|
462
490
|
.option('--id <slug>', 'Id to use (only with a single --from). Default: a slug of the policy name')
|
|
463
491
|
.option('--yes', 'Skip the confirmation prompt')
|
|
464
492
|
.option('-o, --out <path>', 'Write the resulting pack file here (default: stdout; progress goes to stderr)')
|
|
465
|
-
.option('--pack-version <version>', '
|
|
493
|
+
.option('--pack-version <version>', 'New version for the pack and printed file (required when the pack already has a stored version)')
|
|
466
494
|
.addHelpText('after', `
|
|
467
495
|
Examples:
|
|
468
496
|
$ unbound pack adopt k8s-baseline --from "Block kubectl secrets"
|
|
@@ -499,6 +527,11 @@ manage the pack with apply.
|
|
|
499
527
|
const dupes = adoptions.map((r) => r.id).filter((id, i, all) => all.indexOf(id) !== i);
|
|
500
528
|
if (dupes.length) throw new PackError(`two policies would get the same id: ${[...new Set(dupes)].join(', ')}`);
|
|
501
529
|
|
|
530
|
+
const versionState = await remote.fetchVersion(packName);
|
|
531
|
+
if (versionState.supported && versionState.version && !opts.packVersion) {
|
|
532
|
+
throw new PackError(`pack "${packName}" is at v${versionState.version}; pass --pack-version with a greater version for adoption`);
|
|
533
|
+
}
|
|
534
|
+
const packVersion = opts.packVersion || versionState.version || '1.0.0';
|
|
502
535
|
const existing = await remote.fetchManaged(packName);
|
|
503
536
|
for (const r of adoptions) {
|
|
504
537
|
const taken = [...existing.tool, ...existing.generic, ...existing.unsupported.map((u) => u.row)].find((row) => row.code_slug === r.id);
|
|
@@ -510,14 +543,21 @@ manage the pack with apply.
|
|
|
510
543
|
const ok = await confirm(`Mark ${adoptions.length} polic${adoptions.length > 1 ? 'ies' : 'y'} as managed by pack "${packName}"?`);
|
|
511
544
|
if (!ok) { output.warn('Aborted.'); return; }
|
|
512
545
|
}
|
|
513
|
-
|
|
514
|
-
await remote.
|
|
515
|
-
|
|
516
|
-
|
|
546
|
+
if (versionState.supported) {
|
|
547
|
+
await remote.applyPlan(packName, packVersion, adoptions.map((r) => ({
|
|
548
|
+
op: 'adopt', table: r.table, row: r.row,
|
|
549
|
+
body: { origin: 'CODE', code_pack: packName, code_slug: r.id },
|
|
550
|
+
})), { expectedVersion: versionState.version });
|
|
551
|
+
} else {
|
|
552
|
+
output.warn('Older backend: adopting policies one at a time; this run is not atomic.');
|
|
553
|
+
for (const r of adoptions) await remote.updateRow(r.table, r.row.id, { code_pack: packName, code_slug: r.id });
|
|
517
554
|
}
|
|
555
|
+
for (const r of adoptions) output.info(`Adopted "${r.row.name}" as ${packName}/${r.id}`);
|
|
518
556
|
const ref = await remote.loadReference();
|
|
519
|
-
const live =
|
|
520
|
-
|
|
557
|
+
const { live, version } = versionState.supported
|
|
558
|
+
? await fetchStablePack(packName)
|
|
559
|
+
: { live: await remote.fetchManaged(packName), version: packVersion };
|
|
560
|
+
writeOrPrint(stringifyPack(liveToPack(packName, live, ref, { version: version || packVersion })), opts.out);
|
|
521
561
|
} catch (err) {
|
|
522
562
|
fail(err);
|
|
523
563
|
}
|
|
@@ -527,40 +567,52 @@ manage the pack with apply.
|
|
|
527
567
|
.command('delete <pack>')
|
|
528
568
|
.description('Delete every policy a pack manages.')
|
|
529
569
|
.option('--yes', 'Skip the confirmation prompt')
|
|
570
|
+
.option('--pack-version <version>', 'New version to store for the deletion (required when the pack has a stored version)')
|
|
530
571
|
.action(async (packName, opts) => {
|
|
531
572
|
try {
|
|
532
573
|
requireLogin();
|
|
533
574
|
if (!SLUG_RE.test(packName)) throw new PackError('pack name must match [a-z0-9][a-z0-9-]{0,63}');
|
|
575
|
+
const versionState = await remote.fetchVersion(packName);
|
|
534
576
|
const live = await remote.fetchManaged(packName);
|
|
535
577
|
const rows = [
|
|
536
578
|
...live.tool.map((row) => ({ table: 'tool', row })),
|
|
537
579
|
...live.generic.map((row) => ({ table: 'generic', row })),
|
|
538
580
|
];
|
|
581
|
+
if (versionState.supported && live.unsupported.length) {
|
|
582
|
+
throw new PackError(`pack "${packName}" has ${live.unsupported.length} policy type(s) this CLI cannot manage; update the CLI before deleting the pack`);
|
|
583
|
+
}
|
|
539
584
|
for (const { row } of live.unsupported) output.warn(`${row.name}: unsupported policy type, left untouched; delete it in the console.`);
|
|
540
|
-
if (!rows.length) {
|
|
585
|
+
if (!rows.length && (!versionState.supported || !versionState.version)) {
|
|
541
586
|
output.info(`Pack "${packName}" manages no policies of a managed type in this org.`);
|
|
542
587
|
return;
|
|
543
588
|
}
|
|
589
|
+
if (versionState.supported && versionState.version && !opts.packVersion) {
|
|
590
|
+
throw new PackError(`pack "${packName}" is at v${versionState.version}; pass --pack-version with a greater version for deletion`);
|
|
591
|
+
}
|
|
544
592
|
for (const { row } of rows) console.log(` ${row.name}`);
|
|
545
|
-
if (!opts.yes) {
|
|
593
|
+
if (!opts.yes && rows.length) {
|
|
546
594
|
const ok = await confirm(`Delete these ${rows.length} polic${rows.length > 1 ? 'ies' : 'y'}?`);
|
|
547
595
|
if (!ok) { output.warn('Aborted.'); return; }
|
|
548
596
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
597
|
+
if (versionState.supported) {
|
|
598
|
+
await remote.applyPlan(packName, opts.packVersion || versionState.version || '1.0.0',
|
|
599
|
+
rows.map(({ table, row }) => ({ op: 'delete', table, row })),
|
|
600
|
+
{ deletePack: true, expectedVersion: versionState.version });
|
|
601
|
+
for (const { row } of rows) output.success(`Deleted ${row.name}`);
|
|
602
|
+
if (!rows.length) output.success(`Pack "${packName}" has no policies; stored version advanced.`);
|
|
603
|
+
} else {
|
|
604
|
+
output.warn('Older backend: deleting policies one at a time; this run is not atomic.');
|
|
605
|
+
const results = await remote.writeOps(rows.map(({ table, row }) => ({ op: 'delete', table, row })));
|
|
606
|
+
for (const result of results) {
|
|
607
|
+
if (result.status === 'ok') output.success(`Deleted ${result.op.row.name}`);
|
|
608
|
+
else output.error(`${result.op.row.name}: ${result.error}`);
|
|
557
609
|
}
|
|
610
|
+
if (results.some((result) => result.status === 'failed')) process.exitCode = 1;
|
|
558
611
|
}
|
|
559
|
-
if (failed) process.exitCode = 1;
|
|
560
612
|
} catch (err) {
|
|
561
613
|
fail(err);
|
|
562
614
|
}
|
|
563
615
|
});
|
|
564
616
|
}
|
|
565
617
|
|
|
566
|
-
module.exports = { register, _internals: { loadPackFiles, changeText, runApply, confirm } };
|
|
618
|
+
module.exports = { register, _internals: { loadPackFiles, changeText, runApply, fetchStablePack, confirm } };
|
package/src/lib/pack-remote.js
CHANGED
|
@@ -8,6 +8,7 @@ const { PackError, TOOL_TYPES, GENERIC_KINDS } = require('./pack-schema');
|
|
|
8
8
|
const TOOL_PATH = '/api/v1/command-policies/';
|
|
9
9
|
const GENERIC_PATH = '/api/v1/policies/';
|
|
10
10
|
const PAGE_SIZE = 200;
|
|
11
|
+
const PACK_PATH = (name) => `/api/v1/packs/${encodeURIComponent(name)}/`;
|
|
11
12
|
|
|
12
13
|
// Every write declares itself as the code path. The server marks the row
|
|
13
14
|
// `origin: CODE` (so the console only lets people switch it on or off) and lets
|
|
@@ -193,6 +194,35 @@ async function deleteRow(table, id) {
|
|
|
193
194
|
return api.del(pathFor(table, id), { query: { origin: CODE_ORIGIN } });
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
async function fetchVersion(name) {
|
|
198
|
+
try {
|
|
199
|
+
const result = await api.get(PACK_PATH(name));
|
|
200
|
+
return { supported: true, version: result.version || null };
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err && err.statusCode === 404) return { supported: false, version: null };
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function applyPlan(name, version, ops, { deletePack = false, expectedVersion = null } = {}) {
|
|
208
|
+
const operations = ops.map((op) => {
|
|
209
|
+
const operation = { op: op.op, table: op.table };
|
|
210
|
+
if (op.row) operation.id = op.row.id;
|
|
211
|
+
if (op.body) {
|
|
212
|
+
operation.body = {
|
|
213
|
+
...op.body,
|
|
214
|
+
origin: CODE_ORIGIN,
|
|
215
|
+
code_pack: name,
|
|
216
|
+
code_slug: op.desired ? op.desired.id : op.body.code_slug,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return operation;
|
|
220
|
+
});
|
|
221
|
+
return api.post(`${PACK_PATH(name)}apply/`, {
|
|
222
|
+
body: { version, expected_version: expectedVersion, operations, delete_pack: deletePack },
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
196
226
|
/**
|
|
197
227
|
* Id of a freshly created row. The generic endpoint wraps in {policy}, the
|
|
198
228
|
* command-policies endpoint returns the row unwrapped today; both envelopes
|
|
@@ -305,4 +335,6 @@ module.exports = {
|
|
|
305
335
|
tempSlug,
|
|
306
336
|
createdId,
|
|
307
337
|
writeOps,
|
|
338
|
+
fetchVersion,
|
|
339
|
+
applyPlan,
|
|
308
340
|
};
|
package/src/toolHealth.js
CHANGED
|
@@ -206,12 +206,27 @@ function envKeyCheck(label, name, kind = 'aux') {
|
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
function configDeviceToken() {
|
|
210
|
+
try {
|
|
211
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.unbound', 'config.json'), 'utf8'));
|
|
212
|
+
return cfg.api_key || cfg.device_token || null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Env wins when present so a stale exported key is still validated; setup keeps the token in ~/.unbound/config.json and removes the env var.
|
|
210
219
|
function deviceTokenCheck(legacyName) {
|
|
211
220
|
const check = envKeyCheck('API key env', 'UNBOUND_DEVICE_TOKEN');
|
|
212
221
|
if (check.ok) return check;
|
|
213
222
|
const legacy = envKeyCheck('API key env', legacyName);
|
|
214
|
-
|
|
223
|
+
if (legacy.ok) return legacy;
|
|
224
|
+
const value = configDeviceToken();
|
|
225
|
+
if (!value) return check;
|
|
226
|
+
return {
|
|
227
|
+
name: 'API key env', ok: true, kind: 'aux', detail: 'device token set (config.json)', summary: 'API key set',
|
|
228
|
+
keyCheck: true, envName: 'device token', value, candidates: [value],
|
|
229
|
+
};
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
function envCheck(label, name, expected, kind = 'aux') {
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
function runCli(file, port, home, args = ['apply', '-f', file, '--yes']) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = spawn(process.execPath, [path.resolve(__dirname, '../src/index.js'),
|
|
12
|
+
'pack', ...args], {
|
|
13
|
+
env: {
|
|
14
|
+
...process.env, HOME: home, UNBOUND_TOKEN: 'test-token',
|
|
15
|
+
UNBOUND_API_URL: `http://127.0.0.1:${port}`,
|
|
16
|
+
UNBOUND_NO_UPDATE_CHECK: '1', UNBOUND_TELEMETRY: '0',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
let stdout = '';
|
|
20
|
+
let stderr = '';
|
|
21
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
22
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
23
|
+
child.on('error', reject);
|
|
24
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('two CLI applies produce one success and one clear lock conflict', async () => {
|
|
29
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-cli-'));
|
|
30
|
+
const file = path.join(home, 'pack.yaml');
|
|
31
|
+
fs.writeFileSync(file, [
|
|
32
|
+
'apiVersion: unbound.security/v1',
|
|
33
|
+
'kind: PolicyPack',
|
|
34
|
+
'metadata:',
|
|
35
|
+
' name: sample',
|
|
36
|
+
' version: 1.0.0',
|
|
37
|
+
'policies:',
|
|
38
|
+
' - id: budget',
|
|
39
|
+
' kind: cost',
|
|
40
|
+
' config:',
|
|
41
|
+
' monthly_budget: 500',
|
|
42
|
+
'',
|
|
43
|
+
].join('\n'));
|
|
44
|
+
|
|
45
|
+
let versionReads = [];
|
|
46
|
+
let applying = false;
|
|
47
|
+
let liveRows = 0;
|
|
48
|
+
const server = http.createServer((req, res) => {
|
|
49
|
+
const send = (status, body) => {
|
|
50
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify(body));
|
|
52
|
+
};
|
|
53
|
+
if (req.method === 'GET' && req.url.startsWith('/api/v1/policies/form_data/')) {
|
|
54
|
+
return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
|
|
55
|
+
}
|
|
56
|
+
if (req.method === 'GET' && req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
|
|
57
|
+
if (req.method === 'GET' && req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
|
|
58
|
+
if (req.method === 'GET' && req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [] });
|
|
59
|
+
if (req.method === 'GET' && req.url === '/api/v1/packs/sample/') {
|
|
60
|
+
versionReads.push(res);
|
|
61
|
+
if (versionReads.length === 2) {
|
|
62
|
+
for (const pending of versionReads) {
|
|
63
|
+
pending.writeHead(200, { 'Content-Type': 'application/json' });
|
|
64
|
+
pending.end(JSON.stringify({ name: 'sample', version: null }));
|
|
65
|
+
}
|
|
66
|
+
versionReads = [];
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (req.method === 'POST' && req.url === '/api/v1/packs/sample/apply/') {
|
|
71
|
+
if (applying) return send(409, { error: 'another apply is running, retry' });
|
|
72
|
+
applying = true;
|
|
73
|
+
return setTimeout(() => {
|
|
74
|
+
liveRows = 1;
|
|
75
|
+
applying = false;
|
|
76
|
+
send(200, { name: 'sample', version: '1.0.0', operations_applied: 1 });
|
|
77
|
+
}, 250);
|
|
78
|
+
}
|
|
79
|
+
return send(404, { error: 'not found' });
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
83
|
+
const port = server.address().port;
|
|
84
|
+
const results = await Promise.all([runCli(file, port, home), runCli(file, port, home)]);
|
|
85
|
+
assert.deepEqual(results.map((result) => result.code).sort(), [0, 1]);
|
|
86
|
+
assert.equal(liveRows, 1);
|
|
87
|
+
assert.match(results.find((result) => result.code === 1).stderr, /another apply is running, retry/i);
|
|
88
|
+
} finally {
|
|
89
|
+
server.close();
|
|
90
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('folder apply continues after one pack fails atomically', async () => {
|
|
95
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-folder-'));
|
|
96
|
+
for (const name of ['alpha', 'beta']) {
|
|
97
|
+
fs.writeFileSync(path.join(home, `${name}.yaml`), [
|
|
98
|
+
'apiVersion: unbound.security/v1', 'kind: PolicyPack', 'metadata:',
|
|
99
|
+
` name: ${name}`, ' version: 1.0.0', 'policies:',
|
|
100
|
+
` - id: ${name}-budget`, ' kind: cost', ' config:', ' monthly_budget: 500', '',
|
|
101
|
+
].join('\n'));
|
|
102
|
+
}
|
|
103
|
+
const posts = [];
|
|
104
|
+
const server = http.createServer((req, res) => {
|
|
105
|
+
const send = (status, body) => {
|
|
106
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
107
|
+
res.end(JSON.stringify(body));
|
|
108
|
+
};
|
|
109
|
+
if (req.url.startsWith('/api/v1/policies/form_data/')) {
|
|
110
|
+
return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
|
|
111
|
+
}
|
|
112
|
+
if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
|
|
113
|
+
if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
|
|
114
|
+
if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [] });
|
|
115
|
+
const match = req.url.match(/^\/api\/v1\/packs\/(alpha|beta)\/(apply\/)?$/);
|
|
116
|
+
if (match && req.method === 'GET') return send(200, { name: match[1], version: null });
|
|
117
|
+
if (match && req.method === 'POST') {
|
|
118
|
+
posts.push(match[1]);
|
|
119
|
+
return match[1] === 'alpha'
|
|
120
|
+
? send(409, { error: 'another apply is running, retry' })
|
|
121
|
+
: send(200, { name: 'beta', version: '1.0.0', operations_applied: 1 });
|
|
122
|
+
}
|
|
123
|
+
return send(404, { error: 'not found' });
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
127
|
+
const result = await runCli(home, server.address().port, home);
|
|
128
|
+
assert.equal(result.code, 1);
|
|
129
|
+
assert.deepEqual(posts, ['alpha', 'beta']);
|
|
130
|
+
assert.match(result.stderr, /another apply is running, retry/i);
|
|
131
|
+
} finally {
|
|
132
|
+
server.close();
|
|
133
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('in-sync apply avoids an empty write', async () => {
|
|
138
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-noop-'));
|
|
139
|
+
const file = path.join(home, 'sample.yaml');
|
|
140
|
+
fs.writeFileSync(file, [
|
|
141
|
+
'apiVersion: unbound.security/v1', 'kind: PolicyPack', 'metadata:',
|
|
142
|
+
' name: sample', ' version: 1.0.0', 'policies:',
|
|
143
|
+
' - id: budget', ' kind: cost', ' config:', ' monthly_budget: 500', '',
|
|
144
|
+
].join('\n'));
|
|
145
|
+
let posts = 0;
|
|
146
|
+
const row = {
|
|
147
|
+
id: 5, name: 'budget', type: 'COST', origin: 'CODE', code_pack: 'sample',
|
|
148
|
+
code_slug: 'budget', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 },
|
|
149
|
+
scope_user_groups: [], scope_tool_types: [], labels: [],
|
|
150
|
+
};
|
|
151
|
+
const server = http.createServer((req, res) => {
|
|
152
|
+
const send = (status, body) => {
|
|
153
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
154
|
+
res.end(JSON.stringify(body));
|
|
155
|
+
};
|
|
156
|
+
if (req.url.startsWith('/api/v1/policies/form_data/')) {
|
|
157
|
+
return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
|
|
158
|
+
}
|
|
159
|
+
if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
|
|
160
|
+
if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
|
|
161
|
+
if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
|
|
162
|
+
if (req.url === '/api/v1/packs/sample/') return send(200, { name: 'sample', version: '1.0.0' });
|
|
163
|
+
if (req.method === 'POST') posts += 1;
|
|
164
|
+
return send(404, { error: 'not found' });
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
168
|
+
const result = await runCli(file, server.address().port, home);
|
|
169
|
+
assert.equal(result.code, 0, result.stderr);
|
|
170
|
+
assert.equal(posts, 0);
|
|
171
|
+
} finally {
|
|
172
|
+
server.close();
|
|
173
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('delete requires a new version before prompting', async () => {
|
|
178
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-delete-version-'));
|
|
179
|
+
const row = {
|
|
180
|
+
id: 5, name: 'budget', type: 'COST', origin: 'CODE', code_pack: 'sample',
|
|
181
|
+
code_slug: 'budget', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 },
|
|
182
|
+
};
|
|
183
|
+
const server = http.createServer((req, res) => {
|
|
184
|
+
const send = (status, body) => {
|
|
185
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
186
|
+
res.end(JSON.stringify(body));
|
|
187
|
+
};
|
|
188
|
+
if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
|
|
189
|
+
if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
|
|
190
|
+
if (req.url === '/api/v1/packs/sample/') return send(200, { name: 'sample', version: '1.0.0' });
|
|
191
|
+
return send(404, { error: 'not found' });
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
195
|
+
const result = await runCli(null, server.address().port, home, ['delete', 'sample']);
|
|
196
|
+
assert.equal(result.code, 2);
|
|
197
|
+
assert.match(result.stderr, /pass --pack-version/);
|
|
198
|
+
assert.doesNotMatch(result.stdout, /Delete these/);
|
|
199
|
+
} finally {
|
|
200
|
+
server.close();
|
|
201
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('version-only apply stores the new version with an empty plan', async () => {
|
|
206
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-version-only-'));
|
|
207
|
+
const file = path.join(home, 'sample.yaml');
|
|
208
|
+
fs.writeFileSync(file, [
|
|
209
|
+
'apiVersion: unbound.security/v1', 'kind: PolicyPack', 'metadata:',
|
|
210
|
+
' name: sample', ' version: 2.0.0', 'policies:',
|
|
211
|
+
' - id: budget', ' kind: cost', ' config:', ' monthly_budget: 500', '',
|
|
212
|
+
].join('\n'));
|
|
213
|
+
const row = {
|
|
214
|
+
id: 5, name: 'budget', type: 'COST', origin: 'CODE', code_pack: 'sample',
|
|
215
|
+
code_slug: 'budget', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 },
|
|
216
|
+
scope_user_groups: [], scope_tool_types: [], labels: [],
|
|
217
|
+
};
|
|
218
|
+
let version = '1.0.0';
|
|
219
|
+
const writes = [];
|
|
220
|
+
const server = http.createServer((req, res) => {
|
|
221
|
+
const send = (status, body) => {
|
|
222
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify(body));
|
|
224
|
+
};
|
|
225
|
+
if (req.url.startsWith('/api/v1/policies/form_data/')) return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
|
|
226
|
+
if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
|
|
227
|
+
if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
|
|
228
|
+
if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
|
|
229
|
+
if (req.url === '/api/v1/packs/sample/' && req.method === 'GET') return send(200, { name: 'sample', version });
|
|
230
|
+
if (req.url === '/api/v1/packs/sample/apply/' && req.method === 'POST') {
|
|
231
|
+
req.on('data', (chunk) => writes.push(chunk));
|
|
232
|
+
req.on('end', () => { version = '2.0.0'; send(200, { name: 'sample', version, operations_applied: 0 }); });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
return send(404, { error: 'not found' });
|
|
236
|
+
});
|
|
237
|
+
try {
|
|
238
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
239
|
+
const diff = await runCli(file, server.address().port, home, ['diff', '-f', file, '--json']);
|
|
240
|
+
assert.equal(diff.code, 1, diff.stderr);
|
|
241
|
+
assert.equal(JSON.parse(diff.stdout).packs[0].summary.drift, 1);
|
|
242
|
+
const result = await runCli(file, server.address().port, home);
|
|
243
|
+
assert.equal(result.code, 0, result.stderr);
|
|
244
|
+
assert.equal(version, '2.0.0');
|
|
245
|
+
assert.deepEqual(JSON.parse(Buffer.concat(writes).toString()).operations, []);
|
|
246
|
+
} finally {
|
|
247
|
+
server.close();
|
|
248
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('empty pack delete sends a versioned delete plan', async () => {
|
|
253
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-empty-delete-'));
|
|
254
|
+
let body;
|
|
255
|
+
const server = http.createServer((req, res) => {
|
|
256
|
+
const send = (status, data) => {
|
|
257
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
258
|
+
res.end(JSON.stringify(data));
|
|
259
|
+
};
|
|
260
|
+
if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
|
|
261
|
+
if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [] });
|
|
262
|
+
if (req.url === '/api/v1/packs/sample/' && req.method === 'GET') return send(200, { name: 'sample', version: '1.0.0' });
|
|
263
|
+
if (req.url === '/api/v1/packs/sample/apply/' && req.method === 'POST') {
|
|
264
|
+
const chunks = [];
|
|
265
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
266
|
+
req.on('end', () => { body = JSON.parse(Buffer.concat(chunks).toString()); send(200, { name: 'sample', version: '2.0.0', operations_applied: 0 }); });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
return send(404, { error: 'not found' });
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
273
|
+
const result = await runCli(null, server.address().port, home, ['delete', 'sample', '--pack-version', '2.0.0']);
|
|
274
|
+
assert.equal(result.code, 0, result.stderr);
|
|
275
|
+
assert.deepEqual(body, { version: '2.0.0', expected_version: '1.0.0', operations: [], delete_pack: true });
|
|
276
|
+
} finally {
|
|
277
|
+
server.close();
|
|
278
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('adopt refuses to print a pack changed during the final read', async () => {
|
|
283
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-adopt-race-'));
|
|
284
|
+
let versionReads = 0;
|
|
285
|
+
const row = { id: 5, name: 'Budget', type: 'COST', origin: 'MANUAL', enabled: true, status: 'ACTIVE', config: { monthly_budget: 500 } };
|
|
286
|
+
const server = http.createServer((req, res) => {
|
|
287
|
+
const send = (status, data) => {
|
|
288
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
289
|
+
res.end(JSON.stringify(data));
|
|
290
|
+
};
|
|
291
|
+
if (req.url.startsWith('/api/v1/policies/form_data/')) return send(200, { user_groups: [], guardrails: [], ai_models: [], tool_types: [] });
|
|
292
|
+
if (req.url.startsWith('/api/v1/labels/')) return send(200, { labels: [] });
|
|
293
|
+
if (req.url.startsWith('/api/v1/command-policies/')) return send(200, { policies: [], total_pages: 1 });
|
|
294
|
+
if (req.url.startsWith('/api/v1/policies/')) return send(200, { policies: [row] });
|
|
295
|
+
if (req.url === '/api/v1/packs/sample/' && req.method === 'GET') {
|
|
296
|
+
versionReads += 1;
|
|
297
|
+
return send(200, { name: 'sample', version: versionReads === 1 ? '1.0.0' : versionReads === 2 ? '2.0.0' : '3.0.0' });
|
|
298
|
+
}
|
|
299
|
+
if (req.url === '/api/v1/packs/sample/apply/' && req.method === 'POST') return send(200, { name: 'sample', version: '2.0.0', operations_applied: 1 });
|
|
300
|
+
return send(404, { error: 'not found' });
|
|
301
|
+
});
|
|
302
|
+
try {
|
|
303
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
304
|
+
const result = await runCli(null, server.address().port, home, ['adopt', 'sample', '--from', 'Budget', '--pack-version', '2.0.0', '--yes']);
|
|
305
|
+
assert.equal(result.code, 2);
|
|
306
|
+
assert.match(result.stderr, /changed during export; retry/);
|
|
307
|
+
assert.equal(result.stdout, '');
|
|
308
|
+
} finally {
|
|
309
|
+
server.close();
|
|
310
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
@@ -2,7 +2,8 @@ const test = require('node:test');
|
|
|
2
2
|
const assert = require('node:assert/strict');
|
|
3
3
|
const { PassThrough } = require('node:stream');
|
|
4
4
|
|
|
5
|
-
const { confirm } = require('../src/commands/pack')._internals;
|
|
5
|
+
const { confirm, fetchStablePack } = require('../src/commands/pack')._internals;
|
|
6
|
+
const remote = require('../src/lib/pack-remote');
|
|
6
7
|
|
|
7
8
|
test('confirm fails closed when stdin is not a TTY', () => {
|
|
8
9
|
const stdin = new PassThrough();
|
|
@@ -25,3 +26,31 @@ test('confirm resolves true on y', async () => {
|
|
|
25
26
|
stdin.write('y\n');
|
|
26
27
|
assert.equal(await p, true);
|
|
27
28
|
});
|
|
29
|
+
|
|
30
|
+
test('export refuses rows read across a stored version change', async () => {
|
|
31
|
+
const fetchVersion = remote.fetchVersion;
|
|
32
|
+
const fetchManaged = remote.fetchManaged;
|
|
33
|
+
let reads = 0;
|
|
34
|
+
remote.fetchVersion = async () => ({ supported: true, version: ++reads === 1 ? '1.0.0' : '2.0.0' });
|
|
35
|
+
remote.fetchManaged = async () => ({ generic: [{ id: 1 }], tool: [], unsupported: [] });
|
|
36
|
+
try {
|
|
37
|
+
await assert.rejects(() => fetchStablePack('sample'), /changed during export; retry/);
|
|
38
|
+
} finally {
|
|
39
|
+
remote.fetchVersion = fetchVersion;
|
|
40
|
+
remote.fetchManaged = fetchManaged;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('export accepts rows bracketed by the same stored version', async () => {
|
|
45
|
+
const fetchVersion = remote.fetchVersion;
|
|
46
|
+
const fetchManaged = remote.fetchManaged;
|
|
47
|
+
const live = { generic: [{ id: 1 }], tool: [], unsupported: [] };
|
|
48
|
+
remote.fetchVersion = async () => ({ supported: true, version: '2.0.0' });
|
|
49
|
+
remote.fetchManaged = async () => live;
|
|
50
|
+
try {
|
|
51
|
+
assert.deepEqual(await fetchStablePack('sample'), { live, version: '2.0.0' });
|
|
52
|
+
} finally {
|
|
53
|
+
remote.fetchVersion = fetchVersion;
|
|
54
|
+
remote.fetchManaged = fetchManaged;
|
|
55
|
+
}
|
|
56
|
+
});
|
package/test/pack-remote.test.js
CHANGED
|
@@ -25,6 +25,48 @@ function reset() {
|
|
|
25
25
|
stub.onDel = async () => ({});
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
test('fetchVersion preserves a stored version and only treats 404 as an old backend', async () => {
|
|
29
|
+
reset();
|
|
30
|
+
stub.onGet = async () => ({ name: 'base', version: '2.0.0' });
|
|
31
|
+
assert.deepEqual(await remote.fetchVersion('base'), { supported: true, version: '2.0.0' });
|
|
32
|
+
stub.onGet = async () => { throw Object.assign(new Error('missing'), { statusCode: 404 }); };
|
|
33
|
+
assert.deepEqual(await remote.fetchVersion('base'), { supported: false, version: null });
|
|
34
|
+
stub.onGet = async () => { throw Object.assign(new Error('unavailable'), { statusCode: 503 }); };
|
|
35
|
+
await assert.rejects(() => remote.fetchVersion('base'), /unavailable/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('applyPlan submits all operations in one request with a stored-version check', async () => {
|
|
39
|
+
reset();
|
|
40
|
+
await remote.applyPlan('base', '3.0.0', [
|
|
41
|
+
{ op: 'update', table: 'tool', row: { id: 7, code_slug: 'tool' },
|
|
42
|
+
desired: { id: 'tool' }, body: { action: 'AUDIT' } },
|
|
43
|
+
{ op: 'delete', table: 'generic', row: { id: 9 } },
|
|
44
|
+
], { expectedVersion: '2.0.0' });
|
|
45
|
+
assert.equal(calls.length, 1);
|
|
46
|
+
assert.equal(calls[0][1], '/api/v1/packs/base/apply/');
|
|
47
|
+
assert.deepEqual(calls[0][2].body, {
|
|
48
|
+
version: '3.0.0', expected_version: '2.0.0', delete_pack: false,
|
|
49
|
+
operations: [
|
|
50
|
+
{ op: 'update', table: 'tool', id: 7, body: {
|
|
51
|
+
action: 'AUDIT', origin: 'CODE', code_pack: 'base', code_slug: 'tool',
|
|
52
|
+
} },
|
|
53
|
+
{ op: 'delete', table: 'generic', id: 9 },
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('applyPlan retains the chosen slug when adopting a console policy', async () => {
|
|
59
|
+
reset();
|
|
60
|
+
await remote.applyPlan('base', '1.0.0', [{
|
|
61
|
+
op: 'adopt', table: 'generic', row: { id: 7 },
|
|
62
|
+
body: { origin: 'CODE', code_pack: 'base', code_slug: 'chosen-id' },
|
|
63
|
+
}]);
|
|
64
|
+
assert.deepEqual(calls[0][2].body.operations[0], {
|
|
65
|
+
op: 'adopt', table: 'generic', id: 7,
|
|
66
|
+
body: { origin: 'CODE', code_pack: 'base', code_slug: 'chosen-id' },
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
28
70
|
test('replaceRow creates under a temporary name before deleting the old row', async () => {
|
|
29
71
|
reset();
|
|
30
72
|
await remote.replaceRow('tool', { id: '11', name: 'Block x' }, { name: 'Block x', policy_type: 'MCP_TOOL', code_pack: 'base', code_slug: 'x' });
|
package/test/tool-health.test.js
CHANGED
|
@@ -1111,6 +1111,31 @@ test('claude-code: only the per-tool UNBOUND_CLAUDE_API_KEY → healthy', () =>
|
|
|
1111
1111
|
assert.equal(claudeWithEnv({ UNBOUND_CLAUDE_API_KEY: 'k' }).status, 'healthy');
|
|
1112
1112
|
});
|
|
1113
1113
|
|
|
1114
|
+
test('claude-code: device token in config.json and no env → healthy', () => {
|
|
1115
|
+
const t = withHome((tmp, th) => {
|
|
1116
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
|
|
1117
|
+
writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
|
|
1118
|
+
writeFile(path.join(tmp, '.unbound', 'config.json'), JSON.stringify({ device_token: 'k' }));
|
|
1119
|
+
return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
|
|
1120
|
+
});
|
|
1121
|
+
assert.equal(t.status, 'healthy');
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
test('claude-code: an exported key is the one validated even when config.json holds another', () => {
|
|
1125
|
+
process.env.UNBOUND_DEVICE_TOKEN = 'from-env';
|
|
1126
|
+
try {
|
|
1127
|
+
const t = withHome((tmp, th) => {
|
|
1128
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
|
|
1129
|
+
writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
|
|
1130
|
+
writeFile(path.join(tmp, '.unbound', 'config.json'), JSON.stringify({ device_token: 'from-config' }));
|
|
1131
|
+
return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
|
|
1132
|
+
});
|
|
1133
|
+
assert.equal(t.checks.find((c) => c.keyCheck).value, 'from-env');
|
|
1134
|
+
} finally {
|
|
1135
|
+
delete process.env.UNBOUND_DEVICE_TOKEN;
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1114
1139
|
test('claude-code: neither env set → tampered, naming UNBOUND_DEVICE_TOKEN', () => {
|
|
1115
1140
|
const t = claudeWithEnv({});
|
|
1116
1141
|
assert.equal(t.status, 'tampered');
|