troxy-cli 1.21.1 → 1.23.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.
@@ -0,0 +1,218 @@
1
+ // `policies create` used to know about exactly three fields - amount,
2
+ // merchant_name, tx_per_day - because payment was the whole product when it
3
+ // was written. These tests cover the domain-aware rewrite: every field in
4
+ // every checkpoint should be reachable, a field that belongs to more than one
5
+ // domain should require an explicit --domain rather than guessing, and the
6
+ // three condition-building paths (single/--and/--or, --json, --template)
7
+ // should each produce exactly the request body the server expects - checked
8
+ // directly against the built object, not by diffing console output.
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert';
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+ import { _buildConditionsFromFlags, _findTemplate } from '../policies.js';
15
+
16
+ // _buildConditionsFromFlags and _findTemplate call process.exit(1) on a bad
17
+ // input rather than throwing - this makes that path testable without ending
18
+ // the test process, by turning the exit into a catchable error. console.error
19
+ // still prints its message, matching how a user would actually see it.
20
+ function _expectExit(fn) {
21
+ const orig = process.exit;
22
+ process.exit = (code) => { throw new Error(`__EXIT_${code}__`); };
23
+ try {
24
+ fn();
25
+ throw new Error('expected process.exit to be called, but it was not');
26
+ } catch (e) {
27
+ if (!/^__EXIT_\d+__$/.test(e.message)) throw e;
28
+ } finally {
29
+ process.exit = orig;
30
+ }
31
+ }
32
+
33
+ test('a payment field with no --domain still infers payment (backward compat)', () => {
34
+ const r = _buildConditionsFromFlags(
35
+ { action: 'BLOCK', field: 'amount', operator: 'gt', value: '100' }, {}, { requireAction: true },
36
+ );
37
+ assert.strictEqual(r.domain, 'payment');
38
+ assert.deepStrictEqual(r.conditions, [{ field: 'amount', operator: 'gt', value: '100' }]);
39
+ assert.deepStrictEqual(r.or_conditions, []);
40
+ });
41
+
42
+ test('a non-payment field works with an explicit --domain - this is the whole point of the rewrite', () => {
43
+ const r = _buildConditionsFromFlags(
44
+ { action: 'ESCALATE', domain: 'access', field: 'login_action', operator: 'eq', value: 'signup' }, {}, { requireAction: true },
45
+ );
46
+ assert.strictEqual(r.domain, 'access');
47
+ assert.deepStrictEqual(r.conditions, [{ field: 'login_action', operator: 'eq', value: 'signup' }]);
48
+ });
49
+
50
+ test('domain aliases resolve (login -> access, the persisted-column naming trap)', () => {
51
+ const r = _buildConditionsFromFlags(
52
+ { action: 'ESCALATE', domain: 'login', field: 'login_action', operator: 'eq', value: 'signup' }, {}, { requireAction: true },
53
+ );
54
+ assert.strictEqual(r.domain, 'access');
55
+ });
56
+
57
+ test('a field shared by two domains (destination) requires an explicit --domain', () => {
58
+ _expectExit(() => _buildConditionsFromFlags(
59
+ { action: 'BLOCK', field: 'destination', operator: 'eq', value: 'public' }, {}, { requireAction: true },
60
+ ));
61
+ });
62
+
63
+ test('an operator the field does not support is rejected before it reaches the server', () => {
64
+ _expectExit(() => _buildConditionsFromFlags(
65
+ { action: 'BLOCK', domain: 'payment', field: 'amount', operator: 'contains', value: '100' }, {}, { requireAction: true },
66
+ ));
67
+ });
68
+
69
+ test('a boolean field rejects anything but true/false', () => {
70
+ _expectExit(() => _buildConditionsFromFlags(
71
+ { action: 'BLOCK', domain: 'secrets', field: 'has_api_key', operator: 'eq', value: 'yes' }, {}, { requireAction: true },
72
+ ));
73
+ });
74
+
75
+ test('an enum field rejects a value the checkpoint never reports', () => {
76
+ _expectExit(() => _buildConditionsFromFlags(
77
+ { action: 'BLOCK', domain: 'access', field: 'login_action', operator: 'eq', value: 'signin' }, {}, { requireAction: true },
78
+ ));
79
+ });
80
+
81
+ test('repeated --and builds one AND block, no branches', () => {
82
+ const r = _buildConditionsFromFlags(
83
+ { action: 'BLOCK', domain: 'destructive', field: 'action_verb', operator: 'eq', value: 'export' },
84
+ { and: ['field=resource,operator=contains,value=customer'] },
85
+ { requireAction: true },
86
+ );
87
+ assert.deepStrictEqual(r.conditions, [
88
+ { field: 'action_verb', operator: 'eq', value: 'export' },
89
+ { field: 'resource', operator: 'contains', value: 'customer' },
90
+ ]);
91
+ assert.deepStrictEqual(r.or_conditions, []);
92
+ });
93
+
94
+ test('repeated --or builds one branch per alternative, sharing --action', () => {
95
+ const r = _buildConditionsFromFlags(
96
+ { action: 'BLOCK', domain: 'comms' },
97
+ { or: [
98
+ 'field=recipient_domain,operator=contains,value=gmail.com',
99
+ 'field=recipient_domain,operator=contains,value=yahoo.com',
100
+ ] },
101
+ { requireAction: true },
102
+ );
103
+ assert.strictEqual(r.conditions.length, 0);
104
+ assert.strictEqual(r.or_conditions.length, 2);
105
+ assert.deepStrictEqual(r.or_conditions[0], { action: 'BLOCK', conditions: [{ field: 'recipient_domain', operator: 'contains', value: 'gmail.com' }] });
106
+ assert.deepStrictEqual(r.or_conditions[1], { action: 'BLOCK', conditions: [{ field: 'recipient_domain', operator: 'contains', value: 'yahoo.com' }] });
107
+ });
108
+
109
+ test('--and conditions apply to every --or branch, not just the first', () => {
110
+ const r = _buildConditionsFromFlags(
111
+ { action: 'BLOCK', domain: 'destructive', field: 'action_verb', operator: 'eq', value: 'delete' },
112
+ { or: ['field=resource,operator=contains,value=prod', 'field=resource,operator=contains,value=staging'] },
113
+ { requireAction: true },
114
+ );
115
+ assert.strictEqual(r.or_conditions.length, 2);
116
+ for (const branch of r.or_conditions) {
117
+ assert.ok(branch.conditions.some(c => c.field === 'action_verb'), 'the AND condition must be present in every branch');
118
+ }
119
+ });
120
+
121
+ test('a malformed --and (missing operator=) is rejected with a clear error, not a silent partial condition', () => {
122
+ _expectExit(() => _buildConditionsFromFlags(
123
+ { action: 'BLOCK', domain: 'payment' }, { and: ['field=amount,value=100'] }, { requireAction: true },
124
+ ));
125
+ });
126
+
127
+ test('--json is a full escape hatch and is still domain-validated', () => {
128
+ const r = _buildConditionsFromFlags(
129
+ { action: 'ESCALATE', domain: 'model', json: JSON.stringify({ conditions: [{ field: 'output_price', operator: 'gt', value: '10' }] }) },
130
+ {}, { requireAction: true },
131
+ );
132
+ assert.strictEqual(r.domain, 'model');
133
+ assert.deepStrictEqual(r.conditions, [{ field: 'output_price', operator: 'gt', value: '10' }]);
134
+ });
135
+
136
+ test('--json rejects a condition the field spec would reject', () => {
137
+ _expectExit(() => _buildConditionsFromFlags(
138
+ { action: 'BLOCK', domain: 'model', json: JSON.stringify({ conditions: [{ field: 'output_price', operator: 'contains', value: '10' }] }) },
139
+ {}, { requireAction: true },
140
+ ));
141
+ });
142
+
143
+ test('--json cannot be combined with --field', () => {
144
+ _expectExit(() => _buildConditionsFromFlags(
145
+ { action: 'BLOCK', domain: 'payment', field: 'amount', operator: 'gt', value: '1', json: '{}' }, {}, { requireAction: true },
146
+ ));
147
+ });
148
+
149
+ test('--template applies the template\'s own domain, action and conditions', () => {
150
+ const r = _buildConditionsFromFlags({ template: 'Block all destructive actions' }, {}, { requireAction: true });
151
+ assert.strictEqual(r.domain, 'destructive');
152
+ assert.strictEqual(r.action, 'BLOCK');
153
+ assert.deepStrictEqual(r.conditions, []);
154
+ assert.deepStrictEqual(r.or_conditions, []);
155
+ });
156
+
157
+ test('--template --value overrides the threshold on a single-condition template', () => {
158
+ const r = _buildConditionsFromFlags({ template: 'Block models over $10/MTok output', value: '25' }, {}, { requireAction: true });
159
+ assert.strictEqual(r.conditions[0].value, '25');
160
+ assert.strictEqual(r.conditions[0].field, 'output_price'); // unchanged
161
+ });
162
+
163
+ test('--template --value is refused on a template with more than one condition', () => {
164
+ _expectExit(() => _buildConditionsFromFlags({ template: 'Escalate making a file public', value: '5' }, {}, { requireAction: true }));
165
+ });
166
+
167
+ test('--template --action overrides the template default', () => {
168
+ const r = _buildConditionsFromFlags({ template: 'Escalate making a file public', action: 'BLOCK' }, {}, { requireAction: true });
169
+ assert.strictEqual(r.action, 'BLOCK');
170
+ });
171
+
172
+ test('--template cannot be combined with --field', () => {
173
+ _expectExit(() => _buildConditionsFromFlags(
174
+ { template: 'Block all destructive actions', field: 'amount', operator: 'gt', value: '1' }, {}, { requireAction: true },
175
+ ));
176
+ });
177
+
178
+ test('an unknown template name exits with suggestions rather than a stack trace', () => {
179
+ _expectExit(() => _findTemplate('Block all destructve actions')); // typo
180
+ });
181
+
182
+ test('a template is found case-insensitively', () => {
183
+ const t = _findTemplate('block all destructive actions');
184
+ assert.strictEqual(t.label, 'Block all destructive actions');
185
+ });
186
+
187
+ // ── _displayAction: the raw `action` column is stale on essentially every
188
+ // dashboard-built policy; effective_action (added server-side, see
189
+ // troxy-tf-live's policies.py _effective_action) is the resolved value. This
190
+ // is only a fallback for a server that hasn't deployed the field yet.
191
+
192
+ test('_displayAction prefers effective_action when present', async () => {
193
+ const { _displayAction } = await import('../policies.js');
194
+ assert.strictEqual(_displayAction({ action: 'ESCALATE', effective_action: 'BLOCK' }), 'BLOCK');
195
+ });
196
+
197
+ test('_displayAction falls back to the raw action against an older server', async () => {
198
+ const { _displayAction } = await import('../policies.js');
199
+ assert.strictEqual(_displayAction({ action: 'BLOCK' }), 'BLOCK');
200
+ });
201
+
202
+ // ── --describe: handle_ai_policy only knows a binary payment/comms allowlist,
203
+ // gated on is_email. Source-inspection style, matching model-checkpoint.test.js -
204
+ // this branch needs a live login session + org AI key to drive end to end.
205
+
206
+ test('--describe sends is_email when --domain comms is requested', () => {
207
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'policies.js'), 'utf8');
208
+ const start = src.indexOf('if (flags.describe)');
209
+ const chunk = src.slice(start, start + 2000);
210
+ assert.ok(
211
+ /is_email:\s*describeDomain\s*===\s*'comms'/.test(chunk),
212
+ '--describe must forward is_email to api.aiPolicy, or every request silently uses the payment field allowlist regardless of what was described',
213
+ );
214
+ assert.ok(
215
+ /\['payment', 'comms'\]\.includes\(describeDomain\)/.test(chunk),
216
+ '--describe must reject any domain the AI builder does not actually support, rather than silently mis-drafting against the payment allowlist',
217
+ );
218
+ });
@@ -0,0 +1,60 @@
1
+ // Org policy support end to end: api.js has the routes, policies.js checks
2
+ // --org in every subcommand that has both a personal and an org meaning, and
3
+ // a write subcommand requires admin while list/describe only require
4
+ // membership - matching orgs.py's is_org_admin vs is_org_member split.
5
+ //
6
+ // Source-inspection style, like rotate-key.test.js: driving the real
7
+ // subcommands would need a live JWT and a real org, which this test suite
8
+ // doesn't have. What it can check without either is that the wiring exists
9
+ // and that a write path can't skip the admin check.
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { readFileSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const apiSrc = readFileSync(join(__dirname, '..', 'api.js'), 'utf8');
18
+ const polSrc = readFileSync(join(__dirname, '..', 'policies.js'), 'utf8');
19
+
20
+ test('the org policy routes are wired in the api client', () => {
21
+ for (const path of ["'/orgs/current'", '`/orgs/${orgId}/policies`', '`/orgs/${orgId}/policies/${id}`']) {
22
+ assert.ok(apiSrc.includes(path), `${path} not wired in api.js`);
23
+ }
24
+ });
25
+
26
+ test('every write subcommand resolves org context with admin required', () => {
27
+ // Each of these blocks should call _resolveOrgContext(jwt, true) when
28
+ // flags.org is set, mirroring orgs.py's is_org_admin gate on every
29
+ // POST/PATCH/DELETE to /orgs/:id/policies*.
30
+ const writeSubcommands = ["case 'delete'", "case 'pause'", "case 'set-priority'"];
31
+ for (const marker of writeSubcommands) {
32
+ const start = polSrc.indexOf(marker);
33
+ assert.ok(start !== -1, `${marker} not found in policies.js`);
34
+ const chunk = polSrc.slice(start, start + 1200);
35
+ assert.ok(
36
+ /_resolveOrgContext\(jwt,\s*true\)/.test(chunk),
37
+ `${marker} does not call _resolveOrgContext(jwt, true) - an org write could bypass the admin check`,
38
+ );
39
+ }
40
+ });
41
+
42
+ test('create requires admin for --org but read subcommands do not', () => {
43
+ const createStart = polSrc.indexOf("case 'create'");
44
+ const createChunk = polSrc.slice(createStart, createStart + 4000);
45
+ assert.ok(/_resolveOrgContext\(jwt,\s*true\)/.test(createChunk), 'create --org must require admin');
46
+
47
+ // list/describe are read-only - orgs.py's handle_list_org_policies allows
48
+ // any active member, not just admins. Passing `false` here is what makes
49
+ // that distinction actually reach the CLI instead of over-restricting reads.
50
+ const listStart = polSrc.indexOf("case 'list':");
51
+ const listChunk = polSrc.slice(listStart, listStart + 800);
52
+ assert.ok(/_resolveOrgContext\(jwt,\s*false\)/.test(listChunk), 'list --org should not require admin (read access is for any member)');
53
+ });
54
+
55
+ test('org policy creation sends the domain, unlike the old payment-only path', () => {
56
+ const createStart = polSrc.indexOf("case 'create'");
57
+ const chunk = polSrc.slice(createStart, polSrc.indexOf('createOrgPolicy', createStart) + 50);
58
+ assert.ok(/createOrgPolicy\(jwt,\s*org\.id,\s*body\)/.test(chunk));
59
+ assert.ok(/body\s*=\s*\{\s*name,\s*action,\s*domain,\s*conditions,\s*or_conditions/.test(chunk), 'org policy body must include domain - without it every org policy defaults to payment');
60
+ });
@@ -0,0 +1,127 @@
1
+ // Field-name correctness for the five checkpoint-simulate commands added
2
+ // alongside `troxy pay` (send/signin/action/model-call/scan). A typo'd field
3
+ // name here is invisible until someone reads the JSON: the request still
4
+ // posts, the checkpoint still answers, and it just never matches the policy
5
+ // it was meant to test - the same "saves fine, never fires" failure this
6
+ // whole session has been chasing on the dashboard and backend sides.
7
+ //
8
+ // Body-building is exported separately from the network call specifically so
9
+ // this is checkable without a live login session (see simulate.js's own note
10
+ // on why).
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert';
13
+ import { _sendBody, _signinBody, _actionBody, _modelCallBody, _scanBody, _policyName } from '../simulate.js';
14
+
15
+ function _expectExit(fn) {
16
+ const orig = process.exit;
17
+ process.exit = (code) => { throw new Error(`__EXIT_${code}__`); };
18
+ try {
19
+ fn();
20
+ throw new Error('expected process.exit to be called, but it was not');
21
+ } catch (e) {
22
+ if (!/^__EXIT_\d+__$/.test(e.message)) throw e;
23
+ } finally {
24
+ process.exit = orig;
25
+ }
26
+ }
27
+
28
+ test('send builds the exact fields /evaluate/email expects', () => {
29
+ const b = _sendBody({ recipient: 'jane@acme.com', subject: 'Q3', body: 'attached', channel: 'slack' });
30
+ assert.deepStrictEqual(b.recipient, 'jane@acme.com');
31
+ assert.deepStrictEqual(b.recipients, ['jane@acme.com']);
32
+ assert.strictEqual(b.subject, 'Q3');
33
+ assert.strictEqual(b.body, 'attached');
34
+ assert.strictEqual(b.channel, 'slack');
35
+ });
36
+
37
+ test('send defaults channel to email and merges --recipient with --recipients', () => {
38
+ const b = _sendBody({ recipient: 'a@x.com', recipients: 'b@x.com, c@x.com' });
39
+ assert.strictEqual(b.channel, 'email');
40
+ assert.deepStrictEqual(b.recipients, ['a@x.com', 'b@x.com', 'c@x.com']);
41
+ });
42
+
43
+ test('send requires a recipient', () => {
44
+ _expectExit(() => _sendBody({}));
45
+ });
46
+
47
+ test('signin builds login_action, not a made-up "type" field the backend does not read', () => {
48
+ const b = _signinBody({ site: 'coinbase.com', type: 'signup' });
49
+ assert.strictEqual(b.site, 'coinbase.com');
50
+ assert.strictEqual(b.login_action, 'signup');
51
+ assert.ok(!('type' in b), 'the CLI flag is --type but the wire field is login_action - both must not leak through');
52
+ });
53
+
54
+ test('signin defaults to login and rejects anything else', () => {
55
+ assert.strictEqual(_signinBody({ site: 'x.com' }).login_action, 'login');
56
+ _expectExit(() => _signinBody({ site: 'x.com', type: 'signin' })); // not a real value
57
+ });
58
+
59
+ test('action builds action_verb/item_count/reversible, not verb/items/reversible', () => {
60
+ const b = _actionBody({ verb: 'delete', resource: 'prod users table', items: '500', reversible: 'false' });
61
+ assert.strictEqual(b.action_verb, 'delete');
62
+ assert.strictEqual(b.resource, 'prod users table');
63
+ assert.strictEqual(b.item_count, 500);
64
+ assert.strictEqual(b.reversible, false);
65
+ });
66
+
67
+ test('action defaults item_count to 1 and reversible to false', () => {
68
+ const b = _actionBody({ verb: 'share', resource: 'doc' });
69
+ assert.strictEqual(b.item_count, 1);
70
+ assert.strictEqual(b.reversible, false);
71
+ assert.ok(!('destination' in b), 'destination should be omitted, not sent as undefined, when not given');
72
+ });
73
+
74
+ test('action requires verb and resource', () => {
75
+ _expectExit(() => _actionBody({ resource: 'x' }));
76
+ _expectExit(() => _actionBody({ verb: 'delete' }));
77
+ });
78
+
79
+ test('model-call builds estimated_cost, not "cost"', () => {
80
+ const b = _modelCallBody({ model: 'claude-opus-5', provider: 'anthropic', cost: '4.50', tokens: '900000', effort: 'high' });
81
+ assert.strictEqual(b.estimated_cost, 4.5);
82
+ assert.strictEqual(b.tokens, 900000);
83
+ assert.strictEqual(b.effort, 'high');
84
+ assert.ok(!('cost' in b), 'the CLI flag is --cost but the wire field is estimated_cost');
85
+ });
86
+
87
+ test('model-call defaults estimated_cost to 0 and omits tokens/effort when not given', () => {
88
+ const b = _modelCallBody({ model: 'x', provider: 'y' });
89
+ assert.strictEqual(b.estimated_cost, 0);
90
+ assert.ok(!('tokens' in b));
91
+ assert.ok(!('effort' in b));
92
+ });
93
+
94
+ test('scan builds content_type and action, not type and do', () => {
95
+ const b = _scanBody({ content: 'AWS_KEY=abc', destination: 'public', do: 'push', type: 'code' });
96
+ assert.strictEqual(b.content, 'AWS_KEY=abc');
97
+ assert.strictEqual(b.destination, 'public');
98
+ assert.strictEqual(b.action, 'push');
99
+ assert.strictEqual(b.content_type, 'code');
100
+ assert.ok(!('do' in b) && !('type' in b), 'the CLI flags are --do/--type but the wire fields are action/content_type');
101
+ });
102
+
103
+ test('scan defaults destination to public, action to send, content_type to message', () => {
104
+ const b = _scanBody({ content: 'hi' });
105
+ assert.strictEqual(b.destination, 'public');
106
+ assert.strictEqual(b.action, 'send');
107
+ assert.strictEqual(b.content_type, 'message');
108
+ });
109
+
110
+ test('scan requires content', () => {
111
+ _expectExit(() => _scanBody({}));
112
+ });
113
+
114
+ // ── _policyName: reads `policy` where present, else parses `reason` ────────
115
+
116
+ test('prefers the top-level policy field when present (the payment checkpoint shape)', () => {
117
+ assert.strictEqual(_policyName({ policy: 'Block over $100', reason: 'irrelevant' }), 'Block over $100');
118
+ });
119
+
120
+ test('falls back to parsing the reason sentence (every other checkpoint\'s shape)', () => {
121
+ assert.strictEqual(_policyName({ reason: "Matched policy 'Escalate every signup'." }), 'Escalate every signup');
122
+ });
123
+
124
+ test('returns null rather than a wrong guess when nothing matched', () => {
125
+ assert.strictEqual(_policyName({ reason: 'No secrets policy matched; default for high severity is BLOCK.' }), null);
126
+ assert.strictEqual(_policyName({}), null);
127
+ });
@@ -0,0 +1,46 @@
1
+ // The vendored templates (src/data/templates.json) are only useful if every
2
+ // one of them is a condition the backend will actually accept - a template
3
+ // that fails this is a customer's first "troxy policies create --template"
4
+ // saving a rule that never fires, the exact class of bug this whole spec-
5
+ // vendoring pattern exists to catch before it ships.
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { validateCondition } from '../policy-fields.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const { templates } = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'templates.json'), 'utf8'));
15
+
16
+ test('every vendored template has at least one condition or is a deliberate catch-all', () => {
17
+ assert.ok(templates.length >= 60, `expected ~69 templates, found ${templates.length} - did export-templates.mjs run against a stale/empty index.html?`);
18
+ });
19
+
20
+ test('every condition in every template validates against the field spec', () => {
21
+ const failures = [];
22
+ for (const t of templates) {
23
+ const allConds = [
24
+ ...t.conditions,
25
+ ...t.or_conditions.flatMap(b => b.conditions),
26
+ ];
27
+ for (const c of allConds) {
28
+ const err = validateCondition(t.domain, c.field, c.operator, c.value, c.value2);
29
+ if (err) failures.push(`"${t.label}" (${t.domain}): ${c.field} ${c.operator} ${c.value} -> ${err}`);
30
+ }
31
+ }
32
+ assert.deepStrictEqual(failures, [], failures.join('\n'));
33
+ });
34
+
35
+ test('every template action is one the engine recognizes', () => {
36
+ const VALID = new Set(['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY']);
37
+ const bad = templates.filter(t => !VALID.has(t.action));
38
+ assert.deepStrictEqual(bad.map(t => t.label), []);
39
+ });
40
+
41
+ test('a template can be found by exact label', () => {
42
+ const t = templates.find(t => t.label === 'Block all destructive actions');
43
+ assert.ok(t, 'expected the destructive catch-all template to be present');
44
+ assert.strictEqual(t.domain, 'destructive');
45
+ assert.strictEqual(t.conditions.length, 0);
46
+ });