@celilo/cli 0.9.0 → 0.10.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.
@@ -3,149 +3,7 @@
3
3
  */
4
4
 
5
5
  import { describe, expect, test } from 'bun:test';
6
- import {
7
- validateDomain,
8
- validateEmail,
9
- validateIpAddress,
10
- validateIpAddressList,
11
- validateProxmoxUrl,
12
- validateRequired,
13
- validateRequiredNoDefault,
14
- validateSubnet,
15
- } from './validators';
16
-
17
- describe('validateProxmoxUrl', () => {
18
- test('accepts valid HTTPS URL with port', () => {
19
- expect(validateProxmoxUrl('https://proxmox.example.com:8006')).toBeUndefined();
20
- expect(validateProxmoxUrl('https://192.168.1.100:8006')).toBeUndefined();
21
- });
22
-
23
- test('rejects non-HTTPS URLs', () => {
24
- expect(validateProxmoxUrl('http://proxmox.example.com:8006')).toBe(
25
- 'URL must start with https://',
26
- );
27
- });
28
-
29
- test('rejects URLs without port', () => {
30
- expect(validateProxmoxUrl('https://proxmox.example.com')).toBe(
31
- 'URL must include port (e.g., :8006)',
32
- );
33
- });
34
-
35
- test('allows undefined (for default values)', () => {
36
- expect(validateProxmoxUrl(undefined)).toBeUndefined();
37
- });
38
-
39
- test('rejects empty input', () => {
40
- expect(validateProxmoxUrl('')).toBe('URL is required');
41
- expect(validateProxmoxUrl(' ')).toBe('URL is required');
42
- });
43
-
44
- test('rejects invalid URLs', () => {
45
- expect(validateProxmoxUrl('https://:8006')).toBe('Invalid URL format');
46
- expect(validateProxmoxUrl('not-a-url')).toBe('URL must start with https://');
47
- });
48
- });
49
-
50
- describe('validateSubnet', () => {
51
- test('accepts valid CIDR notation', () => {
52
- expect(validateSubnet('10.0.10.0/24')).toBeUndefined();
53
- expect(validateSubnet('192.168.1.0/16')).toBeUndefined();
54
- expect(validateSubnet('172.16.0.0/12')).toBeUndefined();
55
- });
56
-
57
- test('rejects invalid CIDR format', () => {
58
- expect(validateSubnet('10.0.10.0')).toBe('Subnet must be in CIDR format (e.g., 10.0.10.0/24)');
59
- expect(validateSubnet('10.0.10/24')).toBe('Subnet must be in CIDR format (e.g., 10.0.10.0/24)');
60
- });
61
-
62
- test('rejects invalid IP octets', () => {
63
- expect(validateSubnet('256.0.10.0/24')).toBe('Invalid IP address (octets must be 0-255)');
64
- expect(validateSubnet('10.300.10.0/24')).toBe('Invalid IP address (octets must be 0-255)');
65
- });
66
-
67
- test('rejects invalid prefix', () => {
68
- expect(validateSubnet('10.0.10.0/33')).toBe('Invalid prefix length (must be 0-32)');
69
- expect(validateSubnet('10.0.10.0/-1')).toBe('Invalid prefix length (must be 0-32)');
70
- });
71
-
72
- test('allows undefined (for default values)', () => {
73
- expect(validateSubnet(undefined)).toBeUndefined();
74
- });
75
-
76
- test('rejects empty input', () => {
77
- expect(validateSubnet('')).toBe('Subnet is required');
78
- });
79
- });
80
-
81
- describe('validateDomain', () => {
82
- test('accepts valid domains', () => {
83
- expect(validateDomain('homelab.local')).toBeUndefined();
84
- expect(validateDomain('example.com')).toBeUndefined();
85
- expect(validateDomain('sub.domain.co.uk')).toBeUndefined();
86
- });
87
-
88
- test('rejects invalid domain format', () => {
89
- expect(validateDomain('not-a-domain')).toBe('Invalid domain format (e.g., homelab.local)');
90
- expect(validateDomain('.example.com')).toBe('Invalid domain format (e.g., homelab.local)');
91
- expect(validateDomain('example..com')).toBe('Invalid domain format (e.g., homelab.local)');
92
- });
93
-
94
- test('allows undefined (for default values)', () => {
95
- expect(validateDomain(undefined)).toBeUndefined();
96
- });
97
-
98
- test('rejects empty input', () => {
99
- expect(validateDomain('')).toBe('Domain is required');
100
- });
101
- });
102
-
103
- describe('validateEmail', () => {
104
- test('accepts valid emails', () => {
105
- expect(validateEmail('admin@example.com')).toBeUndefined();
106
- expect(validateEmail('user.name@domain.co.uk')).toBeUndefined();
107
- });
108
-
109
- test('rejects invalid email format', () => {
110
- expect(validateEmail('not-an-email')).toBe('Invalid email format');
111
- expect(validateEmail('@example.com')).toBe('Invalid email format');
112
- expect(validateEmail('user@')).toBe('Invalid email format');
113
- });
114
-
115
- test('allows undefined (for default values)', () => {
116
- expect(validateEmail(undefined)).toBeUndefined();
117
- });
118
-
119
- test('rejects empty input', () => {
120
- expect(validateEmail('')).toBe('Email is required');
121
- });
122
- });
123
-
124
- describe('validateIpAddress', () => {
125
- test('accepts valid IP addresses', () => {
126
- expect(validateIpAddress('1.1.1.1')).toBeUndefined();
127
- expect(validateIpAddress('192.168.1.100')).toBeUndefined();
128
- expect(validateIpAddress('10.0.0.1')).toBeUndefined();
129
- });
130
-
131
- test('rejects invalid IP format', () => {
132
- expect(validateIpAddress('not-an-ip')).toBe('Invalid IP format (e.g., 1.1.1.1)');
133
- expect(validateIpAddress('192.168.1')).toBe('Invalid IP format (e.g., 1.1.1.1)');
134
- });
135
-
136
- test('rejects invalid octets', () => {
137
- expect(validateIpAddress('256.1.1.1')).toBe('Invalid IP address (octets must be 0-255)');
138
- expect(validateIpAddress('192.168.1.300')).toBe('Invalid IP address (octets must be 0-255)');
139
- });
140
-
141
- test('allows undefined (for default values)', () => {
142
- expect(validateIpAddress(undefined)).toBeUndefined();
143
- });
144
-
145
- test('rejects empty input', () => {
146
- expect(validateIpAddress('')).toBe('IP address is required');
147
- });
148
- });
6
+ import { validateRequired } from './validators';
149
7
 
150
8
  describe('validateRequired', () => {
151
9
  test('accepts non-empty strings', () => {
@@ -170,66 +28,3 @@ describe('validateRequired', () => {
170
28
  expect(validator('')).toBe('Custom field is required');
171
29
  });
172
30
  });
173
-
174
- describe('validateRequiredNoDefault', () => {
175
- test('accepts non-empty strings', () => {
176
- const validator = validateRequiredNoDefault('Password');
177
- expect(validator('value')).toBeUndefined();
178
- expect(validator(' text ')).toBeUndefined();
179
- });
180
-
181
- test('rejects undefined (no default values)', () => {
182
- const validator = validateRequiredNoDefault('Password');
183
- expect(validator(undefined)).toBe('Password is required');
184
- });
185
-
186
- test('rejects empty strings', () => {
187
- const validator = validateRequiredNoDefault('Password');
188
- expect(validator('')).toBe('Password is required');
189
- expect(validator(' ')).toBe('Password is required');
190
- });
191
-
192
- test('uses custom field name in error message', () => {
193
- const validator = validateRequiredNoDefault('Custom password');
194
- expect(validator('')).toBe('Custom password is required');
195
- });
196
- });
197
-
198
- describe('validateIpAddressList', () => {
199
- test('accepts single IP address', () => {
200
- expect(validateIpAddressList('1.1.1.1')).toBeUndefined();
201
- expect(validateIpAddressList('192.168.1.100')).toBeUndefined();
202
- });
203
-
204
- test('accepts space-separated IP addresses', () => {
205
- expect(validateIpAddressList('8.8.8.8 1.1.1.1')).toBeUndefined();
206
- expect(validateIpAddressList('192.168.0.1 10.0.0.1 1.1.1.1')).toBeUndefined();
207
- });
208
-
209
- test('accepts multiple spaces between IPs', () => {
210
- expect(validateIpAddressList('8.8.8.8 1.1.1.1')).toBeUndefined();
211
- expect(validateIpAddressList('1.1.1.1 8.8.8.8 1.0.0.1')).toBeUndefined();
212
- });
213
-
214
- test('rejects comma-separated IPs', () => {
215
- expect(validateIpAddressList('1.1.1.1,8.8.8.8')).toContain('Invalid IP address');
216
- });
217
-
218
- test('rejects invalid IP in list', () => {
219
- expect(validateIpAddressList('8.8.8.8 999.999.999.999')).toContain(
220
- "Invalid IP address '999.999.999.999'",
221
- );
222
- expect(validateIpAddressList('192.168.0.1 not-an-ip')).toContain(
223
- "Invalid IP address 'not-an-ip'",
224
- );
225
- });
226
-
227
- test('allows undefined (for default values)', () => {
228
- expect(validateIpAddressList(undefined)).toBeUndefined();
229
- });
230
-
231
- test('rejects empty input', () => {
232
- expect(validateIpAddressList('')).toBe('At least one IP address is required');
233
- expect(validateIpAddressList(' ')).toBe('At least one IP address is required');
234
- });
235
- });
@@ -3,137 +3,6 @@
3
3
  * Validation functions for interactive prompts
4
4
  */
5
5
 
6
- /**
7
- * Validate URL format (must start with https:// and include port)
8
- */
9
- export function validateProxmoxUrl(value: string | undefined): string | Error | undefined {
10
- // Allow undefined (default will be used)
11
- if (value === undefined) {
12
- return;
13
- }
14
-
15
- if (!value.trim()) {
16
- return 'URL is required';
17
- }
18
-
19
- if (!value.startsWith('https://')) {
20
- return 'URL must start with https://';
21
- }
22
-
23
- if (!value.includes(':') || !value.match(/:\d+/)) {
24
- return 'URL must include port (e.g., :8006)';
25
- }
26
-
27
- // Basic URL validation
28
- try {
29
- new URL(value);
30
- } catch {
31
- return 'Invalid URL format';
32
- }
33
- }
34
-
35
- /**
36
- * Validate subnet CIDR notation (e.g., 10.0.10.0/24)
37
- */
38
- export function validateSubnet(value: string | undefined): string | Error | undefined {
39
- // Allow undefined (default will be used)
40
- if (value === undefined) {
41
- return;
42
- }
43
-
44
- if (!value.trim()) {
45
- return 'Subnet is required';
46
- }
47
-
48
- // Check CIDR format: IP/prefix
49
- const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/(.+)$/;
50
- if (!cidrPattern.test(value)) {
51
- return 'Subnet must be in CIDR format (e.g., 10.0.10.0/24)';
52
- }
53
-
54
- // Validate IP octets (0-255)
55
- const [ip, prefix] = value.split('/');
56
- const octets = ip.split('.').map(Number);
57
-
58
- for (const octet of octets) {
59
- if (octet < 0 || octet > 255) {
60
- return 'Invalid IP address (octets must be 0-255)';
61
- }
62
- }
63
-
64
- // Validate prefix (0-32)
65
- const prefixNum = Number(prefix);
66
- if (prefixNum < 0 || prefixNum > 32) {
67
- return 'Invalid prefix length (must be 0-32)';
68
- }
69
- }
70
-
71
- /**
72
- * Validate domain name format
73
- */
74
- export function validateDomain(value: string | undefined): string | Error | undefined {
75
- // Allow undefined (default will be used)
76
- if (value === undefined) {
77
- return;
78
- }
79
-
80
- if (!value.trim()) {
81
- return 'Domain is required';
82
- }
83
-
84
- // Basic domain format: lowercase, alphanumeric, hyphens, dots
85
- const domainPattern = /^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,}$/i;
86
- if (!domainPattern.test(value)) {
87
- return 'Invalid domain format (e.g., homelab.local)';
88
- }
89
- }
90
-
91
- /**
92
- * Validate email format
93
- */
94
- export function validateEmail(value: string | undefined): string | Error | undefined {
95
- // Allow undefined (default will be used)
96
- if (value === undefined) {
97
- return;
98
- }
99
-
100
- if (!value.trim()) {
101
- return 'Email is required';
102
- }
103
-
104
- const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
105
- if (!emailPattern.test(value)) {
106
- return 'Invalid email format';
107
- }
108
- }
109
-
110
- /**
111
- * Validate IP address format
112
- */
113
- export function validateIpAddress(value: string | undefined): string | Error | undefined {
114
- // Allow undefined (default will be used)
115
- if (value === undefined) {
116
- return;
117
- }
118
-
119
- if (!value.trim()) {
120
- return 'IP address is required';
121
- }
122
-
123
- const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/;
124
- if (!ipPattern.test(value)) {
125
- return 'Invalid IP format (e.g., 1.1.1.1)';
126
- }
127
-
128
- // Validate octets (0-255)
129
- const octets = value.split('.').map(Number);
130
- for (const octet of octets) {
131
- if (octet < 0 || octet > 255) {
132
- return 'Invalid IP address (octets must be 0-255)';
133
- }
134
- }
135
- }
136
-
137
6
  /**
138
7
  * Validate non-empty string (allows undefined for prompts with default values)
139
8
  */
@@ -149,40 +18,3 @@ export function validateRequired(fieldName: string) {
149
18
  }
150
19
  };
151
20
  }
152
-
153
- /**
154
- * Validate required field with no defaults (for password fields)
155
- */
156
- export function validateRequiredNoDefault(fieldName: string) {
157
- return (value: string | undefined): string | Error | undefined => {
158
- // Reject undefined - no default value for this field
159
- if (value === undefined || !value.trim()) {
160
- return `${fieldName} is required`;
161
- }
162
- };
163
- }
164
-
165
- /**
166
- * Validate space-separated list of IP addresses
167
- */
168
- export function validateIpAddressList(value: string | undefined): string | Error | undefined {
169
- // Allow undefined (default will be used)
170
- if (value === undefined) {
171
- return;
172
- }
173
-
174
- if (!value.trim()) {
175
- return 'At least one IP address is required';
176
- }
177
-
178
- // Split by spaces (any amount of whitespace)
179
- const ips = value.trim().split(/\s+/);
180
-
181
- // Validate each IP
182
- for (const ip of ips) {
183
- const error = validateIpAddress(ip);
184
- if (error) {
185
- return `Invalid IP address '${ip}': ${error}`;
186
- }
187
- }
188
- }
@@ -247,5 +247,57 @@ describe('aspect-approvals', () => {
247
247
  const status = checkAspectApproval('knot-unbound-internal', '1.0.0', newAspect, db);
248
248
  expect(status).toBe('scope_changed');
249
249
  });
250
+
251
+ // #262 — scope-keyed carry-forward across versions. The ISS-0156 cutover
252
+ // hung because consent was recorded at the imported version, then a
253
+ // `module update` bumped the version and the deploy re-prompted (no row for
254
+ // the new version) — and nothing answered the prompt. Per D7, only a SCOPE
255
+ // change requires re-consent; a pure version bump must not.
256
+ it('carries approval forward to a bumped version with the SAME scope (#262 — no re-prompt)', () => {
257
+ insertModule('technitium', '1.0.2+4');
258
+ const db = getDb();
259
+ recordAspectApproval({
260
+ moduleId: 'technitium',
261
+ version: '1.0.2+4',
262
+ scopeHash: computeAspectScopeHash(baseAspect),
263
+ approver: null,
264
+ db,
265
+ });
266
+ // `module update` bumped +4 → +6; aspect scope unchanged.
267
+ expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('approved');
268
+ });
269
+
270
+ it('carries a DENIAL forward to a bumped version with the same scope', () => {
271
+ insertModule('technitium', '1.0.2+4');
272
+ const db = getDb();
273
+ recordAspectConsent({
274
+ moduleId: 'technitium',
275
+ version: '1.0.2+4',
276
+ scopeHash: computeAspectScopeHash(baseAspect),
277
+ approver: null,
278
+ consented: false,
279
+ db,
280
+ });
281
+ expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('denied');
282
+ });
283
+
284
+ it('does NOT carry forward to a bumped version when the scope changed', () => {
285
+ insertModule('technitium', '1.0.2+4');
286
+ const db = getDb();
287
+ recordAspectApproval({
288
+ moduleId: 'technitium',
289
+ version: '1.0.2+4',
290
+ scopeHash: computeAspectScopeHash(baseAspect),
291
+ approver: null,
292
+ db,
293
+ });
294
+ const narrowed: BaseModuleAspect = {
295
+ ansible_role: 'dns-client-config',
296
+ applicable_zones: ['dmz'],
297
+ triggers: ['on_install'],
298
+ };
299
+ // Bumped version AND narrowed scope → no exact row, no scope match → re-prompt.
300
+ expect(checkAspectApproval('technitium', '1.0.2+6', narrowed, db)).toBe('no_approval');
301
+ });
250
302
  });
251
303
  });
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { createHash, randomUUID } from 'node:crypto';
24
- import { and, eq, sql } from 'drizzle-orm';
24
+ import { and, desc, eq, sql } from 'drizzle-orm';
25
25
  import type { getDb } from '../db/client';
26
26
  import { aspectApprovals } from '../db/schema';
27
27
  import type { BaseModuleAspect } from '../manifest/schema';
@@ -65,6 +65,26 @@ export function findAspectApproval(
65
65
  .get();
66
66
  }
67
67
 
68
+ /**
69
+ * Find the most recent consent row for a (moduleId, scopeHash) pair across ALL
70
+ * versions. Used to carry an operator's decision forward when a module version
71
+ * bump leaves the aspect scope (applicable_zones + triggers) unchanged — so a
72
+ * `module update` auto-revision doesn't force re-consent (#262). The latest
73
+ * decision (by approvedAt) for that scope wins.
74
+ */
75
+ export function findAspectApprovalByScope(
76
+ moduleId: string,
77
+ scopeHash: string,
78
+ db: DbClient,
79
+ ): typeof aspectApprovals.$inferSelect | undefined {
80
+ return db
81
+ .select()
82
+ .from(aspectApprovals)
83
+ .where(and(eq(aspectApprovals.moduleId, moduleId), eq(aspectApprovals.scopeHash, scopeHash)))
84
+ .orderBy(desc(aspectApprovals.approvedAt))
85
+ .get();
86
+ }
87
+
68
88
  /**
69
89
  * Record the operator's consent DECISION for a module version's
70
90
  * base-module aspect — `consented: true` (approve) or `false`
@@ -132,9 +152,17 @@ export function recordAspectApproval(args: {
132
152
  * - 'approved': a matching-scope row with `consented: true` — run.
133
153
  * - 'denied': a matching-scope row with `consented: false` — the
134
154
  * operator explicitly refused (ISS-0027). Skip; do NOT re-prompt.
135
- * - 'scope_changed': a row exists but the manifest's scope differs
136
- * (D7 — the prior decision was about a different scope; re-prompt).
137
- * - 'no_approval': no row for this version undecided; prompt.
155
+ * - 'scope_changed': a row exists for THIS version but the manifest's scope
156
+ * differs (D7 — the prior decision was about a different scope; re-prompt).
157
+ * - 'no_approval': no row for this version AND no prior decision for this
158
+ * scope at any version — undecided; prompt.
159
+ *
160
+ * #262: when there's no row for the exact version, the operator's decision is
161
+ * carried forward by SCOPE. A pure version bump (e.g. a `module update`
162
+ * auto-revision) that leaves applicable_zones + triggers unchanged must NOT
163
+ * re-prompt — that re-prompt, unanswerable headlessly, is what hung the
164
+ * ISS-0156 cutover. A genuine scope change has a different scopeHash, so it
165
+ * finds no match and correctly falls through to 'no_approval'.
138
166
  */
139
167
  export function checkAspectApproval(
140
168
  moduleId: string,
@@ -142,9 +170,14 @@ export function checkAspectApproval(
142
170
  aspect: BaseModuleAspect,
143
171
  db: DbClient,
144
172
  ): 'approved' | 'denied' | 'scope_changed' | 'no_approval' {
145
- const existing = findAspectApproval(moduleId, version, db);
146
- if (!existing) return 'no_approval';
147
173
  const currentHash = computeAspectScopeHash(aspect);
148
- if (existing.scopeHash !== currentHash) return 'scope_changed';
149
- return existing.consented ? 'approved' : 'denied';
174
+ const exact = findAspectApproval(moduleId, version, db);
175
+ if (exact) {
176
+ if (exact.scopeHash !== currentHash) return 'scope_changed';
177
+ return exact.consented ? 'approved' : 'denied';
178
+ }
179
+ // No row for this exact version — carry the decision forward by scope (#262).
180
+ const byScope = findAspectApprovalByScope(moduleId, currentHash, db);
181
+ if (byScope) return byScope.consented ? 'approved' : 'denied';
182
+ return 'no_approval';
150
183
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Regression: the programmatic responder (`celilo events respond`) must handle
3
+ * `aspect.required.*` so a HEADLESS deploy of a module with a base_module_aspect
4
+ * can be approved without a TTY.
5
+ *
6
+ * Before the fix it watched only config/secret/ensure/interview — never aspect —
7
+ * so a headless deploy whose aspect consent wasn't pre-recorded emitted
8
+ * `aspect.required.<m>.<role>` and hung forever (busInterview uses timeoutMs:0;
9
+ * no responder ever replied). This reproduced the ISS-0156 cutover hang and is
10
+ * the core of #262.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { defineEvents, openBus } from '@celilo/event-bus';
18
+ import { closeDb, getDb } from '../db/client';
19
+ import { runMigrations } from '../db/migrate';
20
+ import { type AspectRequiredPayload, EVENT_TYPES } from './bus-interview';
21
+ import { startProgrammaticResponder } from './programmatic-responder';
22
+
23
+ const NO_SCHEMAS = defineEvents({});
24
+
25
+ const ASPECT_PAYLOAD: AspectRequiredPayload = {
26
+ module: 'technitium',
27
+ role: 'dns-client-config',
28
+ zones: ['dmz', 'app', 'secure', 'internal'],
29
+ triggers: ['on_install'],
30
+ trigger: 'on_install',
31
+ reason: 'no_approval',
32
+ };
33
+
34
+ /** Emit the aspect-consent query and return the responder's decision, or null. */
35
+ async function askAspectConsent(
36
+ busPath: string,
37
+ module: string,
38
+ role: string,
39
+ payload: AspectRequiredPayload,
40
+ ): Promise<boolean | null> {
41
+ const bus = openBus({ dbPath: busPath, events: NO_SCHEMAS });
42
+ try {
43
+ const replies = await bus.query(
44
+ EVENT_TYPES.aspectRequired(module, role) as never,
45
+ payload as never,
46
+ {
47
+ timeoutMs: 3000,
48
+ pollIntervalMs: 100,
49
+ expect: 'first',
50
+ },
51
+ );
52
+ if (replies.length === 0) return null;
53
+ return (replies[0].payload as { consented?: boolean }).consented ?? null;
54
+ } finally {
55
+ bus.close();
56
+ }
57
+ }
58
+
59
+ describe('programmatic responder — aspect.required consent (#262)', () => {
60
+ let dir: string;
61
+ let busPath: string;
62
+ let db: ReturnType<typeof getDb>;
63
+
64
+ beforeEach(async () => {
65
+ dir = mkdtempSync(join(tmpdir(), 'celilo-resp-aspect-'));
66
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
67
+ busPath = join(dir, 'bus.db');
68
+ await runMigrations(process.env.CELILO_DB_PATH);
69
+ db = getDb();
70
+ });
71
+
72
+ afterEach(() => {
73
+ closeDb();
74
+ rmSync(dir, { recursive: true, force: true });
75
+ process.env.CELILO_DB_PATH = undefined;
76
+ });
77
+
78
+ it('approves (consented=true) when the policy approves the module', async () => {
79
+ const handle = startProgrammaticResponder({
80
+ busDbPath: busPath,
81
+ db,
82
+ onMissing: 'skip',
83
+ values: { aspects: { technitium: true } },
84
+ });
85
+ try {
86
+ const decision = await askAspectConsent(
87
+ busPath,
88
+ 'technitium',
89
+ 'dns-client-config',
90
+ ASPECT_PAYLOAD,
91
+ );
92
+ expect(decision).toBe(true);
93
+ } finally {
94
+ handle.close();
95
+ }
96
+ });
97
+
98
+ it("approves via the '*' wildcard policy", async () => {
99
+ const handle = startProgrammaticResponder({
100
+ busDbPath: busPath,
101
+ db,
102
+ onMissing: 'skip',
103
+ values: { aspects: { '*': true } },
104
+ });
105
+ try {
106
+ const decision = await askAspectConsent(
107
+ busPath,
108
+ 'technitium',
109
+ 'dns-client-config',
110
+ ASPECT_PAYLOAD,
111
+ );
112
+ expect(decision).toBe(true);
113
+ } finally {
114
+ handle.close();
115
+ }
116
+ });
117
+
118
+ it('refuses (consented=false) when the policy denies the module', async () => {
119
+ const handle = startProgrammaticResponder({
120
+ busDbPath: busPath,
121
+ db,
122
+ onMissing: 'skip',
123
+ values: { aspects: { technitium: false } },
124
+ });
125
+ try {
126
+ const decision = await askAspectConsent(
127
+ busPath,
128
+ 'technitium',
129
+ 'dns-client-config',
130
+ ASPECT_PAYLOAD,
131
+ );
132
+ expect(decision).toBe(false);
133
+ } finally {
134
+ handle.close();
135
+ }
136
+ });
137
+
138
+ it('does not reply when no aspect policy is provided (onMissing: skip)', async () => {
139
+ const handle = startProgrammaticResponder({
140
+ busDbPath: busPath,
141
+ db,
142
+ onMissing: 'skip',
143
+ values: {},
144
+ });
145
+ try {
146
+ const decision = await askAspectConsent(
147
+ busPath,
148
+ 'technitium',
149
+ 'dns-client-config',
150
+ ASPECT_PAYLOAD,
151
+ );
152
+ expect(decision).toBeNull();
153
+ } finally {
154
+ handle.close();
155
+ }
156
+ });
157
+ });