unbound-cli 1.18.0 → 1.20.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 +63 -0
- package/package.json +3 -2
- package/src/auth-pages.js +3 -2
- package/src/auth.js +12 -5
- package/src/commands/login.js +24 -2
- package/src/commands/pack.js +566 -0
- package/src/commands/status.js +9 -1
- package/src/index.js +11 -0
- package/src/lib/pack-export.js +136 -0
- package/src/lib/pack-plan.js +515 -0
- package/src/lib/pack-remote.js +308 -0
- package/src/lib/pack-schema.js +328 -0
- package/src/login-scope.js +85 -0
- package/test/auth-server-leak.test.js +8 -1
- package/test/login-scope-callback.test.js +79 -0
- package/test/login-scope.test.js +51 -0
- package/test/pack-confirm.test.js +27 -0
- package/test/pack-export.test.js +83 -0
- package/test/pack-plan.test.js +201 -0
- package/test/pack-remote.test.js +179 -0
- package/test/pack-schema.test.js +144 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
// Stub the HTTP layer before pack-remote loads it.
|
|
6
|
+
const calls = [];
|
|
7
|
+
const stub = {
|
|
8
|
+
get: async (p, opts) => { calls.push(['GET', p, opts]); return stub.onGet(p, opts); },
|
|
9
|
+
post: async (p, opts) => { calls.push(['POST', p, opts]); return stub.onPost(p, opts); },
|
|
10
|
+
put: async (p, opts) => { calls.push(['PUT', p, opts]); return stub.onPut(p, opts); },
|
|
11
|
+
del: async (p, opts) => { calls.push(['DELETE', p, opts]); return stub.onDel(p, opts); },
|
|
12
|
+
onGet: async () => ({}),
|
|
13
|
+
onPost: async () => ({ id: '99' }),
|
|
14
|
+
onPut: async () => ({}),
|
|
15
|
+
onDel: async () => ({}),
|
|
16
|
+
};
|
|
17
|
+
require.cache[require.resolve('../src/api')] = { id: path.resolve(__dirname, '../src/api.js'), filename: path.resolve(__dirname, '../src/api.js'), loaded: true, exports: stub };
|
|
18
|
+
const remote = require('../src/lib/pack-remote');
|
|
19
|
+
|
|
20
|
+
function reset() {
|
|
21
|
+
calls.length = 0;
|
|
22
|
+
stub.onGet = async () => ({});
|
|
23
|
+
stub.onPost = async () => ({ id: '99' });
|
|
24
|
+
stub.onPut = async () => ({});
|
|
25
|
+
stub.onDel = async () => ({});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('replaceRow creates under a temporary name before deleting the old row', async () => {
|
|
29
|
+
reset();
|
|
30
|
+
await remote.replaceRow('tool', { id: '11', name: 'Block x' }, { name: 'Block x', policy_type: 'MCP_TOOL', code_pack: 'base', code_slug: 'x' });
|
|
31
|
+
assert.deepEqual(calls.map((c) => c[0]), ['POST', 'DELETE', 'PUT']);
|
|
32
|
+
assert.match(calls[0][2].body.name, /^Block x-replacing-/);
|
|
33
|
+
assert.equal(calls[1][1], '/api/v1/command-policies/11/');
|
|
34
|
+
assert.deepEqual(calls[2], ['PUT', '/api/v1/command-policies/99/', { body: { name: 'Block x', code_pack: 'base', code_slug: 'x', origin: 'CODE' } }]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('replaceRow: a failed create leaves the old row in place', async () => {
|
|
38
|
+
reset();
|
|
39
|
+
stub.onPost = async () => { throw new Error('boom'); };
|
|
40
|
+
await assert.rejects(() => remote.replaceRow('generic', { id: 5, name: 'base/y' }, { name: 'base/y', type: 'COST' }), /boom/);
|
|
41
|
+
assert.deepEqual(calls.map((c) => c[0]), ['POST'], 'no delete after a failed create');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('replaceRow reads the id from the generic {policy: ...} envelope', async () => {
|
|
45
|
+
reset();
|
|
46
|
+
stub.onPost = async () => ({ policy: { id: 42 } });
|
|
47
|
+
await remote.replaceRow('generic', { id: 5, name: 'base/y' }, { name: 'base/y', type: 'COST' });
|
|
48
|
+
assert.equal(calls[2][1], '/api/v1/policies/42/');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('writeOps never throws for one failed op and reports every outcome', async () => {
|
|
52
|
+
reset();
|
|
53
|
+
stub.onPut = async () => { throw new Error('nope'); };
|
|
54
|
+
const results = await remote.writeOps([
|
|
55
|
+
{ op: 'create', table: 'generic', body: { name: 'base/a' } },
|
|
56
|
+
{ op: 'update', table: 'tool', row: { id: '1' }, body: { action: 'AUDIT' } },
|
|
57
|
+
{ op: 'unchanged', table: 'tool', row: { id: '2' } },
|
|
58
|
+
]);
|
|
59
|
+
assert.deepEqual(results.map((r) => r.status), ['ok', 'failed', 'skipped']);
|
|
60
|
+
assert.equal(results[1].error, 'nope');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('fetchManaged asks for the pack by identity and splits unsupported types', async () => {
|
|
64
|
+
reset();
|
|
65
|
+
stub.onGet = async (p) => {
|
|
66
|
+
if (p.startsWith('/api/v1/command-policies/')) {
|
|
67
|
+
return { policies: [
|
|
68
|
+
{ id: '1', name: 'Audit pushes', policy_type: 'TERMINAL_COMMAND', origin: 'CODE', code_pack: 'base', code_slug: 'a' },
|
|
69
|
+
{ id: '2', name: 'Skill', policy_type: 'SKILL_INJECTION', origin: 'CODE', code_pack: 'base', code_slug: 'skill' },
|
|
70
|
+
{ id: '3', name: 'base/a', policy_type: 'TERMINAL_COMMAND', origin: 'MANUAL' },
|
|
71
|
+
], total_pages: 1 };
|
|
72
|
+
}
|
|
73
|
+
return { policies: [
|
|
74
|
+
{ id: 10, name: 'Cap', type: 'COST', origin: 'CODE', code_pack: 'base', code_slug: 'cost' },
|
|
75
|
+
{ id: 11, name: 'Repo', type: 'REPO', origin: 'CODE', code_pack: 'base', code_slug: 'repo' },
|
|
76
|
+
{ id: 12, name: 'Other', type: 'COST', origin: 'CODE', code_pack: 'other', code_slug: 'x' },
|
|
77
|
+
] };
|
|
78
|
+
};
|
|
79
|
+
const live = await remote.fetchManaged('base');
|
|
80
|
+
assert.deepEqual(live.tool.map((r) => r.name), ['Audit pushes'], 'a MANUAL row named like a pack policy is not managed');
|
|
81
|
+
assert.deepEqual(live.generic.map((r) => r.name), ['Cap'], 'a row of another pack is not managed');
|
|
82
|
+
assert.deepEqual(live.unsupported.map((u) => u.row.name), ['Skill', 'Repo']);
|
|
83
|
+
assert.deepEqual(calls[0][2].query, { code_pack: 'base', origin: 'CODE', page: 1, page_size: 200 });
|
|
84
|
+
assert.deepEqual(calls[1][2].query, { code_pack: 'base', origin: 'CODE' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('fetchAllGrouped walks every page and groups by code_pack', async () => {
|
|
88
|
+
reset();
|
|
89
|
+
stub.onGet = async (p, opts) => {
|
|
90
|
+
if (p.startsWith('/api/v1/command-policies/')) {
|
|
91
|
+
const page = opts.query.page;
|
|
92
|
+
return { policies: [{ id: String(page), name: `policy ${page}`, policy_type: 'MCP_TOOL', origin: 'CODE', code_pack: `p${page}`, code_slug: 'a' }], total_pages: 2 };
|
|
93
|
+
}
|
|
94
|
+
return { policies: [{ id: 1, name: 'loose', type: 'COST', origin: 'MANUAL' }] };
|
|
95
|
+
};
|
|
96
|
+
const { packs, unmanaged } = await remote.fetchAllGrouped();
|
|
97
|
+
assert.deepEqual([...packs.keys()].sort(), ['p1', 'p2']);
|
|
98
|
+
assert.equal(unmanaged.length, 1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('replaceRow uses a temporary name and identity and rolls back when the old delete fails', async () => {
|
|
102
|
+
reset();
|
|
103
|
+
const slug = remote.tempSlug('a-very-long-policy-identifier-that-is-close-to-the-sixty-four-limit');
|
|
104
|
+
assert.ok(/^[a-z0-9-]{1,64}$/.test(slug), slug);
|
|
105
|
+
assert.match(remote.tempName('Block rm'), /^Block rm-replacing-/);
|
|
106
|
+
stub.onDel = async (p) => { if (p.endsWith('/11/')) throw new Error('old delete failed'); return {}; };
|
|
107
|
+
await assert.rejects(() => remote.replaceRow('tool', { id: '11', name: 'x' }, { name: 'x', code_pack: 'base', code_slug: 'x' }), /old delete failed/);
|
|
108
|
+
assert.deepEqual(calls.map((c) => [c[0], c[1]]), [['POST', '/api/v1/command-policies/'], ['DELETE', '/api/v1/command-policies/11/'], ['DELETE', '/api/v1/command-policies/99/']]);
|
|
109
|
+
assert.match(calls[0][2].body.code_slug, /^x-replacing-/);
|
|
110
|
+
assert.equal(calls[0][2].body.code_pack, 'base');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('createdId tolerates the response envelopes the CLI accepts', () => {
|
|
114
|
+
assert.equal(remote.createdId('tool', { id: '7', name: 'x' }), '7');
|
|
115
|
+
assert.equal(remote.createdId('tool', { command_policy: { id: '8' } }), '8');
|
|
116
|
+
assert.equal(remote.createdId('tool', { policy: { id: '9' } }), '9');
|
|
117
|
+
assert.equal(remote.createdId('generic', { policy: { id: 10 } }), 10);
|
|
118
|
+
assert.equal(remote.createdId('generic', {}), null);
|
|
119
|
+
assert.equal(remote.createdId('generic', null), null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('findByExactName tags rows of unmanaged types as unsupported', async () => {
|
|
123
|
+
reset();
|
|
124
|
+
stub.onGet = async (p) => (p.startsWith('/api/v1/command-policies/')
|
|
125
|
+
? { policies: [{ id: '1', name: 'Skill', policy_type: 'SKILL_INJECTION' }, { id: '2', name: 'Skill2', policy_type: 'MCP_TOOL' }], total_pages: 1 }
|
|
126
|
+
: { policies: [] });
|
|
127
|
+
const hits = await remote.findByExactName('Skill');
|
|
128
|
+
assert.equal(hits.length, 1);
|
|
129
|
+
assert.equal(hits[0].supported, false);
|
|
130
|
+
const ok = await remote.findByExactName('Skill2');
|
|
131
|
+
assert.equal(ok[0].supported, true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('every write declares origin CODE; delete carries it as a query parameter', async () => {
|
|
135
|
+
reset();
|
|
136
|
+
await remote.createRow('tool', { name: 'base/x', action: 'AUDIT' });
|
|
137
|
+
await remote.updateRow('generic', 23, { enabled: true });
|
|
138
|
+
await remote.deleteRow('tool', '11');
|
|
139
|
+
assert.deepEqual(calls[0], ['POST', '/api/v1/command-policies/', { body: { name: 'base/x', action: 'AUDIT', origin: 'CODE' } }]);
|
|
140
|
+
assert.deepEqual(calls[1], ['PUT', '/api/v1/policies/23/', { body: { enabled: true, origin: 'CODE' } }]);
|
|
141
|
+
assert.deepEqual(calls[2], ['DELETE', '/api/v1/command-policies/11/', { query: { origin: 'CODE' } }]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('loadReference records why the MCP catalogue is missing instead of hiding it', async () => {
|
|
145
|
+
reset();
|
|
146
|
+
remote.resetReference();
|
|
147
|
+
stub.onGet = async (p) => {
|
|
148
|
+
if (p.includes('mcp-servers')) throw new Error('503 from gateway-data');
|
|
149
|
+
if (p.includes('labels')) return { labels: [] };
|
|
150
|
+
return { user_groups: [], guardrails: [], ai_models: [], tool_types: [] };
|
|
151
|
+
};
|
|
152
|
+
const ref = await remote.loadReference({ mcp: true });
|
|
153
|
+
assert.deepEqual(ref.mcpServers, []);
|
|
154
|
+
assert.match(ref.mcpServersError, /503 from gateway-data/);
|
|
155
|
+
remote.resetReference();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a backend without the origin field is refused before any write', async () => {
|
|
159
|
+
reset();
|
|
160
|
+
stub.onGet = async (p) => (p.startsWith('/api/v1/command-policies/')
|
|
161
|
+
? { policies: [{ id: '1', name: 'Block rm', policy_type: 'TERMINAL_COMMAND' }], total_pages: 1 }
|
|
162
|
+
: { policies: [{ id: 10, name: 'Cap', type: 'COST' }] });
|
|
163
|
+
await assert.rejects(() => remote.fetchManaged('base'), /newer server release/);
|
|
164
|
+
await assert.rejects(() => remote.fetchAllGrouped(), /newer server release/);
|
|
165
|
+
// An empty org cannot be told apart and simply has nothing to manage yet.
|
|
166
|
+
stub.onGet = async () => ({ policies: [], total_pages: 1 });
|
|
167
|
+
const live = await remote.fetchManaged('base');
|
|
168
|
+
assert.deepEqual(live, { tool: [], generic: [], unsupported: [] });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('fetchAllNames maps every live name to its owner', async () => {
|
|
172
|
+
reset();
|
|
173
|
+
stub.onGet = async (p) => (p.startsWith('/api/v1/command-policies/')
|
|
174
|
+
? { policies: [{ id: '1', name: 'Block rm', policy_type: 'TERMINAL_COMMAND', origin: 'MANUAL' }], total_pages: 1 }
|
|
175
|
+
: { policies: [{ id: 10, name: 'Cap', type: 'COST', origin: 'CODE', code_pack: 'base', code_slug: 'cap' }] });
|
|
176
|
+
const names = await remote.fetchAllNames();
|
|
177
|
+
assert.deepEqual(names.get('Block rm'), { table: 'tool', id: '1', code_pack: null, code_slug: null });
|
|
178
|
+
assert.deepEqual(names.get('Cap'), { table: 'generic', id: 10, code_pack: 'base', code_slug: 'cap' });
|
|
179
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const schema = require('../src/lib/pack-schema');
|
|
5
|
+
|
|
6
|
+
const BASE = `
|
|
7
|
+
apiVersion: unbound.security/v1
|
|
8
|
+
kind: PolicyPack
|
|
9
|
+
metadata:
|
|
10
|
+
name: k8s-baseline
|
|
11
|
+
version: 1.2.0
|
|
12
|
+
defaults:
|
|
13
|
+
scope:
|
|
14
|
+
userGroups: [Engineering]
|
|
15
|
+
policies:
|
|
16
|
+
- id: block-secret-reads
|
|
17
|
+
kind: tool
|
|
18
|
+
type: TERMINAL_COMMAND
|
|
19
|
+
action: BLOCK
|
|
20
|
+
commandFamily: custom
|
|
21
|
+
customMessage: Reading cluster secrets is blocked.
|
|
22
|
+
config:
|
|
23
|
+
command: "regex:kubectl\\\\s+get\\\\s+secrets?"
|
|
24
|
+
- id: github-writes
|
|
25
|
+
kind: tool
|
|
26
|
+
type: MCP_TOOL
|
|
27
|
+
action: REQUIRE_SLACK_APPROVAL
|
|
28
|
+
mcp:
|
|
29
|
+
server: github
|
|
30
|
+
tools: [create_pull_request, push_files]
|
|
31
|
+
scope:
|
|
32
|
+
userGroups: [Contractors]
|
|
33
|
+
- id: pii-guard
|
|
34
|
+
kind: security
|
|
35
|
+
priority: 10
|
|
36
|
+
guardrails:
|
|
37
|
+
- name: PII
|
|
38
|
+
action: BLOCK
|
|
39
|
+
threshold: 5
|
|
40
|
+
- id: monthly-cap
|
|
41
|
+
kind: cost
|
|
42
|
+
enabled: false
|
|
43
|
+
config:
|
|
44
|
+
monthly_budget: 5000
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
function load(text, file = 'pack.yaml') {
|
|
48
|
+
return schema.validatePack(schema.parsePackText(text, file), file);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('validatePack: happy path merges defaults and builds server names', () => {
|
|
52
|
+
const pack = load(BASE);
|
|
53
|
+
assert.equal(pack.name, 'k8s-baseline');
|
|
54
|
+
assert.equal(pack.version, '1.2.0');
|
|
55
|
+
assert.equal(pack.policies.length, 4);
|
|
56
|
+
const [terminal, mcp, security, cost] = pack.policies;
|
|
57
|
+
assert.equal(terminal.serverName, 'block-secret-reads');
|
|
58
|
+
assert.equal(terminal.enabled, true);
|
|
59
|
+
assert.deepEqual(terminal.scope, { userGroups: ['Engineering'] });
|
|
60
|
+
assert.deepEqual(mcp.scope, { userGroups: ['Contractors'] }, 'policy scope wins over defaults');
|
|
61
|
+
assert.equal(security.priority, 10);
|
|
62
|
+
assert.equal(cost.enabled, false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('parsePackText: JSON by extension, YAML otherwise', () => {
|
|
66
|
+
const doc = { apiVersion: 'unbound.security/v1', kind: 'PolicyPack', metadata: { name: 'p', version: '1' }, policies: [{ id: 'a', kind: 'cost', config: { monthly_budget: 1 } }] };
|
|
67
|
+
assert.deepEqual(schema.parsePackText(JSON.stringify(doc), 'p.json'), doc);
|
|
68
|
+
assert.deepEqual(schema.parsePackText('a: 1\n', 'p.yaml'), { a: 1 });
|
|
69
|
+
assert.throws(() => schema.parsePackText('a: [\n', 'bad.yaml'), (err) => err instanceof schema.PackError && err.exitCode === 2);
|
|
70
|
+
assert.throws(() => schema.parsePackText('a: 1\n---\nb: 2\n', 'two.yaml'), /exactly one YAML document/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('validatePack: rejects wrong apiVersion and kind', () => {
|
|
74
|
+
assert.throws(() => load(BASE.replace('unbound.security/v1', 'unbound.security/v2')), /apiVersion must be/);
|
|
75
|
+
assert.throws(() => load(BASE.replace('kind: PolicyPack', 'kind: Pack')), /kind must be/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('validatePack: unknown keys fail at every level', () => {
|
|
79
|
+
assert.throws(() => load(`${BASE}\nextra: 1\n`), /top level: unknown key "extra"/);
|
|
80
|
+
assert.throws(() => load(BASE.replace(' version: 1.2.0', ' version: 1.2.0\n author: x')), /metadata: unknown key "author"/);
|
|
81
|
+
assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n colour: red')), /unknown key "colour"/);
|
|
82
|
+
assert.throws(() => load(BASE.replace(' server: github', ' server: github\n port: 1')), /mcp: unknown key "port"/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('validatePack: slug rules and duplicate ids', () => {
|
|
86
|
+
assert.throws(() => load(BASE.replace('name: k8s-baseline', 'name: K8s Baseline')), /metadata.name/);
|
|
87
|
+
assert.throws(() => load(BASE.replace('id: monthly-cap', 'id: Monthly Cap')), /policies\[3\].id/);
|
|
88
|
+
assert.throws(() => load(BASE.replace('id: monthly-cap', 'id: pii-guard')), /used twice/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('validatePack: kind-specific keys are enforced', () => {
|
|
92
|
+
// tool-only key on a generic policy
|
|
93
|
+
assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n action: BLOCK')), /unknown key "action"/);
|
|
94
|
+
// generic-only key on a tool policy
|
|
95
|
+
assert.throws(() => load(BASE.replace(' kind: tool\n type: TERMINAL_COMMAND', ' kind: tool\n priority: 1\n type: TERMINAL_COMMAND')), /unknown key "priority"/);
|
|
96
|
+
// toolTypes scope only on generic
|
|
97
|
+
assert.throws(() => load(BASE.replace(' userGroups: [Contractors]', ' toolTypes: [cursor]')), /scope.toolTypes applies to/);
|
|
98
|
+
// guardrails only on security
|
|
99
|
+
assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n guardrails: []')), /unknown key "guardrails"/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('validatePack: tool policy rules', () => {
|
|
103
|
+
assert.throws(() => load(BASE.replace('action: BLOCK\n commandFamily', 'action: DENY\n commandFamily')), /action must be one of/);
|
|
104
|
+
assert.throws(() => load(BASE.replace(' customMessage: Reading cluster secrets is blocked.\n', '')), /customMessage is required when action is BLOCK or WARN/);
|
|
105
|
+
assert.throws(() => load(BASE.replace(' commandFamily: custom\n', '')), /commandFamily is required/);
|
|
106
|
+
assert.throws(() => load(BASE.replace(' tools: [create_pull_request, push_files]', ' tool: a\n actionType: write')), /exactly one of tool, tools or actionType/);
|
|
107
|
+
assert.throws(() => load(BASE.replace(' tools: [create_pull_request, push_files]', ' actionType: nuke')), /actionType must be one of/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('validatePack: generic policy rules', () => {
|
|
111
|
+
assert.throws(() => load(BASE.replace(' threshold: 5', ' threshold: 500')), /threshold must be an integer from 1 to 100/);
|
|
112
|
+
assert.throws(() => load(BASE.replace(' action: BLOCK\n threshold: 5', ' action: ROUTE\n')), /routeModel is required when action is ROUTE/);
|
|
113
|
+
assert.throws(() => load(BASE.replace(' config:\n monthly_budget: 5000', ' config: {}')), /cost policy needs a config/);
|
|
114
|
+
assert.throws(() => load(BASE.replace(' priority: 10', ' priority: 10\n config:\n guardrails: []')), /either guardrails or config.guardrails/);
|
|
115
|
+
const modelBad = BASE.replace(' kind: cost\n enabled: false\n config:\n monthly_budget: 5000', ' kind: model\n models:\n allowed: [a]\n allowAll: true');
|
|
116
|
+
assert.throws(() => load(modelBad), /allowed cannot be combined/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('validatePack: policies must be a non-empty list', () => {
|
|
120
|
+
assert.throws(() => load(BASE.replace(/policies:[\s\S]*$/, 'policies: []\n')), /non-empty list/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('slugify turns a console name into a policy id', () => {
|
|
124
|
+
assert.equal(schema.slugify('Block kubectl secrets!'), 'block-kubectl-secrets');
|
|
125
|
+
assert.equal(schema.slugify(' '), null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('validatePack: name is optional free text, unique within the pack', () => {
|
|
129
|
+
const base = { apiVersion: 'unbound.security/v1', kind: 'PolicyPack', metadata: { name: 'p', version: '1' } };
|
|
130
|
+
const ok = schema.validatePack({ ...base, policies: [
|
|
131
|
+
{ id: 'a', name: 'Monthly cap for engineering', kind: 'cost', config: { monthly_budget: 1 } },
|
|
132
|
+
{ id: 'b', kind: 'cost', config: { monthly_budget: 2 } },
|
|
133
|
+
] }, 'p.yaml');
|
|
134
|
+
assert.deepEqual(ok.policies.map((x) => x.serverName), ['Monthly cap for engineering', 'b']);
|
|
135
|
+
assert.throws(() => schema.validatePack({ ...base, policies: [{ id: 'a', name: ' ', kind: 'cost', config: { monthly_budget: 1 } }] }, 'p.yaml'), /name must be a non-empty string/);
|
|
136
|
+
assert.throws(() => schema.validatePack({ ...base, policies: [
|
|
137
|
+
{ id: 'a', name: 'Same', kind: 'cost', config: { monthly_budget: 1 } }, { id: 'b', name: 'Same', kind: 'cost', config: { monthly_budget: 2 } },
|
|
138
|
+
] }, 'p.yaml'), /"Same" is used twice/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('validatePack: description is a tool-policy key only', () => {
|
|
142
|
+
assert.throws(() => load(BASE.replace(' kind: cost', ' kind: cost\n description: dropped')), /unknown key "description"/);
|
|
143
|
+
assert.doesNotThrow(() => load(BASE.replace(' kind: tool\n type: TERMINAL_COMMAND', ' kind: tool\n description: kept\n type: TERMINAL_COMMAND')));
|
|
144
|
+
});
|