@celilo/cli 0.3.4 → 0.3.9

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,327 @@
1
+ /**
2
+ * Layer 1 — bus-mediated secret-interview flow.
3
+ *
4
+ * Drives `interviewForMissingSecrets` against a real sqlite bus + real
5
+ * encrypted store + a programmatic test responder. No fixture modules,
6
+ * no machines, no clack — the responder is just a `bus.watch` that
7
+ * mimics what `terminal-responder.ts` does.
8
+ *
9
+ * Covers what stage 3 introduced:
10
+ * - `user_provided` / `user_password` / `generated_optional` go through
11
+ * the bus; the responder writes the secret out-of-band; the deploy
12
+ * reads from the store after ack.
13
+ * - `generated` and derived secrets bypass the bus entirely.
14
+ */
15
+
16
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
17
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
21
+ import { type DbClient, getDb } from '../db/client';
22
+ import { modules } from '../db/schema';
23
+ import { getOrCreateMasterKey } from '../secrets/master-key';
24
+ import type { SecretRequiredPayload } from './bus-interview';
25
+ import {
26
+ interviewForMissingSecrets,
27
+ readModuleSecretKey,
28
+ writeModuleSecretKey,
29
+ } from './config-interview';
30
+
31
+ const NO_SCHEMAS = defineEvents({});
32
+
33
+ interface ResponderReply {
34
+ /** Value the responder "types" — written out-of-band before ack. */
35
+ value: string;
36
+ /**
37
+ * If true, fall back to auto-generate using the payload's `generate`
38
+ * hint (mimics what terminal-responder does on empty input for
39
+ * `generated_optional`). Overrides `value` when set.
40
+ */
41
+ autoGenerate?: boolean;
42
+ }
43
+
44
+ /**
45
+ * In-process test responder. Watches a single secret.required event
46
+ * type and replies with a fixed value (or auto-generates) — mirrors
47
+ * the writeModuleSecretKey + emit-ack flow that terminal-responder
48
+ * does on a TTY.
49
+ *
50
+ * Returns a `seen` ref that captures the payloads the responder saw,
51
+ * so tests can assert on what the deploy emitted.
52
+ */
53
+ function startTestResponder(
54
+ bus: Bus,
55
+ pattern: string,
56
+ reply: ResponderReply,
57
+ db: DbClient,
58
+ ): { seen: Array<{ payload: SecretRequiredPayload; type: string }>; close: () => void } {
59
+ const seen: Array<{ payload: SecretRequiredPayload; type: string }> = [];
60
+
61
+ const handle = bus.watch(pattern, async (event) => {
62
+ if (event.replyFor !== null) return;
63
+ const payload = event.payload as SecretRequiredPayload;
64
+ seen.push({ payload, type: event.type });
65
+
66
+ let value = reply.value;
67
+ if (reply.autoGenerate && payload.generate) {
68
+ const { generateSecret } = await import('../secrets/generators');
69
+ value = generateSecret(payload.generate);
70
+ }
71
+
72
+ const masterKey = await getOrCreateMasterKey();
73
+ await writeModuleSecretKey(payload.module, payload.key, value, db, masterKey);
74
+
75
+ bus.emitRaw(
76
+ `${event.type}.reply`,
77
+ { acknowledged: true },
78
+ { replyFor: event.id, emittedBy: 'test-responder' },
79
+ );
80
+ });
81
+
82
+ return { seen, close: () => handle.close() };
83
+ }
84
+
85
+ function writeSecretsSchema(
86
+ dir: string,
87
+ properties: Record<
88
+ string,
89
+ {
90
+ source: 'generated' | 'user_provided' | 'user_password' | 'generated_optional';
91
+ format?: string;
92
+ length?: number;
93
+ derive_from?: string;
94
+ derive_method?: string;
95
+ description?: string;
96
+ }
97
+ >,
98
+ ): void {
99
+ mkdirSync(join(dir, 'schema'), { recursive: true });
100
+ const schema = {
101
+ type: 'object',
102
+ properties: Object.fromEntries(
103
+ Object.entries(properties).map(([name, props]) => [name, { type: 'string', ...props }]),
104
+ ),
105
+ };
106
+ writeFileSync(join(dir, 'schema', 'secrets.json'), JSON.stringify(schema));
107
+ }
108
+
109
+ describe('bus-mediated interviewForMissingSecrets', () => {
110
+ let tempDir: string;
111
+ let testDb: DbClient;
112
+ let bus: Bus;
113
+ let busDbPath: string;
114
+
115
+ beforeEach(() => {
116
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-bus-secret-'));
117
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
118
+ busDbPath = join(tempDir, 'events.db');
119
+ process.env.EVENT_BUS_DB = busDbPath;
120
+
121
+ testDb = getDb();
122
+ testDb
123
+ .insert(modules)
124
+ .values({
125
+ id: 'testmod',
126
+ name: 'Test Module',
127
+ sourcePath: tempDir,
128
+ version: '1.0.0',
129
+ manifestData: {},
130
+ })
131
+ .run();
132
+
133
+ bus = openBus({ dbPath: busDbPath, events: NO_SCHEMAS });
134
+ });
135
+
136
+ afterEach(() => {
137
+ bus.close();
138
+ rmSync(tempDir, { recursive: true, force: true });
139
+ process.env.CELILO_DB_PATH = undefined;
140
+ process.env.EVENT_BUS_DB = undefined;
141
+ });
142
+
143
+ test('user_provided: emits secret.required, responder writes, value lands in store', async () => {
144
+ // No schema file → defaults to user_provided per
145
+ // interviewForMissingSecrets's `metadata?.source || 'user_provided'`.
146
+ // Use a snake_case secret name to also exercise the pattern
147
+ // compiler's LIKE escape — pre-0.1.4 this would have thrown.
148
+ const { seen, close } = startTestResponder(
149
+ bus,
150
+ 'secret.required.testmod.api_key',
151
+ { value: 'sk-12345' },
152
+ testDb,
153
+ );
154
+
155
+ const result = await interviewForMissingSecrets(
156
+ 'testmod',
157
+ [{ name: 'api_key', source: 'secret', description: 'API key for upstream' }],
158
+ testDb,
159
+ );
160
+
161
+ expect(result.success).toBe(true);
162
+ expect(result.configured).toContain('api_key (secret)');
163
+
164
+ expect(seen.length).toBe(1);
165
+ expect(seen[0].type).toBe('secret.required.testmod.api_key');
166
+ expect(seen[0].payload.module).toBe('testmod');
167
+ expect(seen[0].payload.key).toBe('api_key');
168
+ expect(seen[0].payload.style).toBe('user_provided');
169
+ expect(seen[0].payload.description).toBe('API key for upstream');
170
+
171
+ // The bus payload must NEVER carry the secret value.
172
+ expect(seen[0].payload).not.toHaveProperty('value');
173
+
174
+ const masterKey = await getOrCreateMasterKey();
175
+ const stored = await readModuleSecretKey('testmod', 'api_key', testDb, masterKey);
176
+ expect(stored).toBe('sk-12345');
177
+
178
+ close();
179
+ });
180
+
181
+ test('user_password: payload carries style:user_password', async () => {
182
+ writeSecretsSchema(tempDir, {
183
+ admin_password: { source: 'user_password', description: 'Admin login' },
184
+ });
185
+
186
+ const { seen, close } = startTestResponder(
187
+ bus,
188
+ 'secret.required.testmod.admin_password',
189
+ { value: 'hunter2' },
190
+ testDb,
191
+ );
192
+
193
+ const result = await interviewForMissingSecrets(
194
+ 'testmod',
195
+ [{ name: 'admin_password', source: 'secret' }],
196
+ testDb,
197
+ );
198
+
199
+ expect(result.success).toBe(true);
200
+ expect(seen.length).toBe(1);
201
+ expect(seen[0].payload.style).toBe('user_password');
202
+
203
+ const masterKey = await getOrCreateMasterKey();
204
+ const stored = await readModuleSecretKey('testmod', 'admin_password', testDb, masterKey);
205
+ expect(stored).toBe('hunter2');
206
+
207
+ close();
208
+ });
209
+
210
+ test('generated_optional: payload carries generate hints + style', async () => {
211
+ writeSecretsSchema(tempDir, {
212
+ session_key: { source: 'generated_optional', format: 'hex', length: 16 },
213
+ });
214
+
215
+ const { seen, close } = startTestResponder(
216
+ bus,
217
+ 'secret.required.testmod.session_key',
218
+ { value: 'user-typed-it' },
219
+ testDb,
220
+ );
221
+
222
+ const result = await interviewForMissingSecrets(
223
+ 'testmod',
224
+ [{ name: 'session_key', source: 'secret' }],
225
+ testDb,
226
+ );
227
+
228
+ expect(result.success).toBe(true);
229
+ expect(seen[0].payload.style).toBe('generated_optional');
230
+ expect(seen[0].payload.generate).toEqual({ format: 'hex', length: 16 });
231
+
232
+ const masterKey = await getOrCreateMasterKey();
233
+ const stored = await readModuleSecretKey('testmod', 'session_key', testDb, masterKey);
234
+ expect(stored).toBe('user-typed-it');
235
+
236
+ close();
237
+ });
238
+
239
+ test('generated_optional + responder auto-generates: secret in store has the generated shape', async () => {
240
+ writeSecretsSchema(tempDir, {
241
+ session_key: { source: 'generated_optional', format: 'hex', length: 16 },
242
+ });
243
+
244
+ const { close } = startTestResponder(
245
+ bus,
246
+ 'secret.required.testmod.session_key',
247
+ { value: '', autoGenerate: true },
248
+ testDb,
249
+ );
250
+
251
+ const result = await interviewForMissingSecrets(
252
+ 'testmod',
253
+ [{ name: 'session_key', source: 'secret' }],
254
+ testDb,
255
+ );
256
+
257
+ expect(result.success).toBe(true);
258
+
259
+ const masterKey = await getOrCreateMasterKey();
260
+ const stored = await readModuleSecretKey('testmod', 'session_key', testDb, masterKey);
261
+ // hex format with 16 bytes → 32 hex chars.
262
+ expect(stored).toMatch(/^[0-9a-f]{32}$/);
263
+
264
+ close();
265
+ });
266
+
267
+ test('source=generated bypasses the bus (no event fired, no responder needed)', async () => {
268
+ writeSecretsSchema(tempDir, {
269
+ auto_token: { source: 'generated', format: 'base64', length: 32 },
270
+ });
271
+
272
+ // Wildcard catches any spurious user-input bus event.
273
+ const fired: string[] = [];
274
+ const handle = bus.watch('secret.required.testmod.*', (event) => {
275
+ if (event.replyFor !== null) return;
276
+ fired.push(event.type);
277
+ });
278
+
279
+ const result = await interviewForMissingSecrets(
280
+ 'testmod',
281
+ [{ name: 'auto_token', source: 'secret' }],
282
+ testDb,
283
+ );
284
+
285
+ expect(result.success).toBe(true);
286
+ expect(fired).toEqual([]);
287
+
288
+ const masterKey = await getOrCreateMasterKey();
289
+ const stored = await readModuleSecretKey('testmod', 'auto_token', testDb, masterKey);
290
+ expect(stored).toBeTruthy();
291
+ expect(stored?.length).toBeGreaterThan(0);
292
+
293
+ handle.close();
294
+ });
295
+
296
+ test('manifest generate field overrides schema source — also bypasses bus', async () => {
297
+ writeSecretsSchema(tempDir, {
298
+ // Schema says user_provided, but the missing-variable carries an
299
+ // explicit `generate` block. The latter wins per the
300
+ // `hasManifestGenerate` priority in interviewForMissingSecrets.
301
+ override_secret: { source: 'user_provided' },
302
+ });
303
+
304
+ const fired: string[] = [];
305
+ const handle = bus.watch('secret.required.testmod.*', (event) => {
306
+ if (event.replyFor !== null) return;
307
+ fired.push(event.type);
308
+ });
309
+
310
+ const result = await interviewForMissingSecrets(
311
+ 'testmod',
312
+ [
313
+ {
314
+ name: 'override_secret',
315
+ source: 'secret',
316
+ generate: { method: 'random', length: 16, encoding: 'hex' },
317
+ },
318
+ ],
319
+ testDb,
320
+ );
321
+
322
+ expect(result.success).toBe(true);
323
+ expect(fired).toEqual([]);
324
+
325
+ handle.close();
326
+ });
327
+ });