@fgv/ks 5.1.0-32 → 5.1.0-33

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.
@@ -4,7 +4,8 @@ jest.mock('../../src/io', () => {
4
4
  return {
5
5
  ...actual,
6
6
  promptHidden: jest.fn(),
7
- promptVisible: jest.fn()
7
+ promptVisible: jest.fn(),
8
+ copyTextToClipboard: jest.fn()
8
9
  };
9
10
  });
10
11
 
@@ -13,17 +14,20 @@ jest.mock('../../src/keystore', () => {
13
14
 
14
15
  return {
15
16
  ...actual,
16
- storeSecret: jest.fn()
17
+ storeSecret: jest.fn(),
18
+ readSecret: jest.fn(),
19
+ openKeystore: jest.fn(),
20
+ saveKeystoreFile: jest.fn()
17
21
  };
18
22
  });
19
23
 
20
24
  import '@fgv/ts-utils-jest';
21
25
 
22
- import { succeed } from '@fgv/ts-utils';
26
+ import { fail, succeed } from '@fgv/ts-utils';
23
27
 
24
28
  import { KsCli } from '../../src/app';
25
- import { promptHidden, promptVisible } from '../../src/io';
26
- import { storeSecret } from '../../src/keystore';
29
+ import { copyTextToClipboard, promptHidden, promptVisible } from '../../src/io';
30
+ import { openKeystore, readSecret, saveKeystoreFile, storeSecret } from '../../src/keystore';
27
31
 
28
32
  describe('KsCli put command', () => {
29
33
  const promptHiddenMock = jest.mocked(promptHidden);
@@ -110,3 +114,265 @@ describe('KsCli put command', () => {
110
114
  );
111
115
  });
112
116
  });
117
+
118
+ describe('KsCli get command', () => {
119
+ const readSecretMock = jest.mocked(readSecret);
120
+ const copyTextToClipboardMock = jest.mocked(copyTextToClipboard);
121
+ const originalFgvPassword = process.env.FGV_KS_PASSWORD;
122
+ const originalKsPassword = process.env.KS_PASSWORD;
123
+ let consoleLogSpy: jest.SpyInstance;
124
+ let consoleErrorSpy: jest.SpyInstance;
125
+ let exitSpy: jest.SpyInstance;
126
+
127
+ beforeEach(() => {
128
+ process.env.FGV_KS_PASSWORD = 'test-password';
129
+ delete process.env.KS_PASSWORD;
130
+
131
+ readSecretMock.mockReset();
132
+ copyTextToClipboardMock.mockReset();
133
+ readSecretMock.mockResolvedValue(succeed('hello'));
134
+ copyTextToClipboardMock.mockResolvedValue(succeed('copied'));
135
+
136
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
137
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
138
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation(((__code?: number) => {
139
+ throw new Error('process.exit');
140
+ }) as never);
141
+ });
142
+
143
+ afterEach(() => {
144
+ consoleLogSpy.mockRestore();
145
+ consoleErrorSpy.mockRestore();
146
+ exitSpy.mockRestore();
147
+ });
148
+
149
+ afterAll(() => {
150
+ if (originalFgvPassword === undefined) {
151
+ delete process.env.FGV_KS_PASSWORD;
152
+ } else {
153
+ process.env.FGV_KS_PASSWORD = originalFgvPassword;
154
+ }
155
+ if (originalKsPassword === undefined) {
156
+ delete process.env.KS_PASSWORD;
157
+ } else {
158
+ process.env.KS_PASSWORD = originalKsPassword;
159
+ }
160
+ });
161
+
162
+ test('emits the raw secret to stdout by default (text encoding)', async () => {
163
+ await new KsCli().run(['node', 'ks', 'get', 'my-key']);
164
+ expect(consoleLogSpy).toHaveBeenCalledWith('hello');
165
+ });
166
+
167
+ test('emits the base64-encoded secret with --encoding base64', async () => {
168
+ await new KsCli().run(['node', 'ks', 'get', 'my-key', '--encoding', 'base64']);
169
+ expect(consoleLogSpy).toHaveBeenCalledWith('aGVsbG8=');
170
+ });
171
+
172
+ test('emits the hex-encoded secret with --encoding hex', async () => {
173
+ await new KsCli().run(['node', 'ks', 'get', 'my-key', '--encoding', 'hex']);
174
+ expect(consoleLogSpy).toHaveBeenCalledWith('68656c6c6f');
175
+ });
176
+
177
+ test('copies the encoded value to the clipboard when --clipboard is set', async () => {
178
+ await new KsCli().run(['node', 'ks', 'get', 'my-key', '--clipboard', '--encoding', 'base64']);
179
+ expect(copyTextToClipboardMock).toHaveBeenCalledWith('aGVsbG8=');
180
+ expect(consoleLogSpy).not.toHaveBeenCalled();
181
+ });
182
+
183
+ test('rejects unknown encoding values', async () => {
184
+ await expect(new KsCli().run(['node', 'ks', 'get', 'my-key', '--encoding', 'utf16'])).rejects.toThrow(
185
+ 'process.exit'
186
+ );
187
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/invalid encoding 'utf16'/i));
188
+ });
189
+
190
+ test('round-trips non-ASCII UTF-8 bytes through base64', async () => {
191
+ readSecretMock.mockResolvedValue(succeed('café-✓'));
192
+ await new KsCli().run(['node', 'ks', 'get', 'my-key', '--encoding', 'base64']);
193
+ const emitted = consoleLogSpy.mock.calls[0][0] as string;
194
+ expect(Buffer.from(emitted, 'base64').toString('utf8')).toBe('café-✓');
195
+ });
196
+ });
197
+
198
+ describe('KsCli export command', () => {
199
+ const openKeystoreMock = jest.mocked(openKeystore);
200
+ const saveKeystoreFileMock = jest.mocked(saveKeystoreFile);
201
+ const copyTextToClipboardMock = jest.mocked(copyTextToClipboard);
202
+ const promptHiddenMock = jest.mocked(promptHidden);
203
+ const originalFgvPassword = process.env.FGV_KS_PASSWORD;
204
+ const originalKsPassword = process.env.KS_PASSWORD;
205
+ let consoleLogSpy: jest.SpyInstance;
206
+ let consoleErrorSpy: jest.SpyInstance;
207
+ let exitSpy: jest.SpyInstance;
208
+
209
+ const makeKeystore = (secrets: Record<string, string>): unknown => ({
210
+ listSecrets: jest.fn(() => succeed(Object.keys(secrets))),
211
+ getApiKey: jest.fn((name: string) =>
212
+ secrets[name] !== undefined ? succeed(secrets[name]) : fail(`Secret '${name}' not found`)
213
+ ),
214
+ importApiKey: jest.fn(() => Promise.resolve(succeed({ entry: {}, replaced: false }))),
215
+ save: jest.fn(() => Promise.resolve(succeed({ format: 'keystore-v1' })))
216
+ });
217
+
218
+ beforeEach(() => {
219
+ process.env.FGV_KS_PASSWORD = 'test-password';
220
+ delete process.env.KS_PASSWORD;
221
+
222
+ openKeystoreMock.mockReset();
223
+ saveKeystoreFileMock.mockReset();
224
+ copyTextToClipboardMock.mockReset();
225
+ promptHiddenMock.mockReset();
226
+ copyTextToClipboardMock.mockResolvedValue(succeed('copied'));
227
+ saveKeystoreFileMock.mockReturnValue(succeed('/mock/keystore'));
228
+
229
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
230
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
231
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation(((__code?: number) => {
232
+ throw new Error('process.exit');
233
+ }) as never);
234
+ });
235
+
236
+ afterEach(() => {
237
+ consoleLogSpy.mockRestore();
238
+ consoleErrorSpy.mockRestore();
239
+ exitSpy.mockRestore();
240
+ });
241
+
242
+ afterAll(() => {
243
+ if (originalFgvPassword === undefined) {
244
+ delete process.env.FGV_KS_PASSWORD;
245
+ } else {
246
+ process.env.FGV_KS_PASSWORD = originalFgvPassword;
247
+ }
248
+ if (originalKsPassword === undefined) {
249
+ delete process.env.KS_PASSWORD;
250
+ } else {
251
+ process.env.KS_PASSWORD = originalKsPassword;
252
+ }
253
+ });
254
+
255
+ test('renders the template with raw secret values by default', async () => {
256
+ openKeystoreMock.mockResolvedValue(
257
+ succeed({
258
+ path: '/mock/keystore',
259
+ keystore: makeKeystore({ xai: 'hello' }) as never
260
+ })
261
+ );
262
+
263
+ await new KsCli().run(['node', 'ks', 'export', '--template-string', 'export X={{xai}}']);
264
+
265
+ expect(consoleLogSpy).toHaveBeenCalledWith("export X='hello'");
266
+ });
267
+
268
+ test('renders the template with base64-encoded secret values', async () => {
269
+ openKeystoreMock.mockResolvedValue(
270
+ succeed({
271
+ path: '/mock/keystore',
272
+ keystore: makeKeystore({ xai: 'hello' }) as never
273
+ })
274
+ );
275
+
276
+ await new KsCli().run([
277
+ 'node',
278
+ 'ks',
279
+ 'export',
280
+ '--template-string',
281
+ 'export X={{xai}}',
282
+ '--encoding',
283
+ 'base64'
284
+ ]);
285
+
286
+ expect(consoleLogSpy).toHaveBeenCalledWith("export X='aGVsbG8='");
287
+ });
288
+
289
+ test('renders the template with hex-encoded secret values', async () => {
290
+ openKeystoreMock.mockResolvedValue(
291
+ succeed({
292
+ path: '/mock/keystore',
293
+ keystore: makeKeystore({ xai: 'hello' }) as never
294
+ })
295
+ );
296
+
297
+ await new KsCli().run([
298
+ 'node',
299
+ 'ks',
300
+ 'export',
301
+ '--template-string',
302
+ 'export X={{xai}}',
303
+ '--encoding',
304
+ 'hex'
305
+ ]);
306
+
307
+ expect(consoleLogSpy).toHaveBeenCalledWith("export X='68656c6c6f'");
308
+ });
309
+
310
+ test('rejects unknown encoding values', async () => {
311
+ await expect(
312
+ new KsCli().run([
313
+ 'node',
314
+ 'ks',
315
+ 'export',
316
+ '--template-string',
317
+ 'export X={{xai}}',
318
+ '--encoding',
319
+ 'utf16'
320
+ ])
321
+ ).rejects.toThrow('process.exit');
322
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringMatching(/invalid encoding 'utf16'/i));
323
+ });
324
+
325
+ test('copies the encoded rendered template to the clipboard', async () => {
326
+ openKeystoreMock.mockResolvedValue(
327
+ succeed({
328
+ path: '/mock/keystore',
329
+ keystore: makeKeystore({ xai: 'hello' }) as never
330
+ })
331
+ );
332
+
333
+ await new KsCli().run([
334
+ 'node',
335
+ 'ks',
336
+ 'export',
337
+ '--template-string',
338
+ 'export X={{xai}}',
339
+ '--encoding',
340
+ 'base64',
341
+ '--clipboard'
342
+ ]);
343
+
344
+ expect(copyTextToClipboardMock).toHaveBeenCalledWith("export X='aGVsbG8='");
345
+ expect(consoleLogSpy).not.toHaveBeenCalled();
346
+ });
347
+
348
+ test('persists the raw unencoded prompted value with --persist-missing + non-text encoding', async () => {
349
+ const keystore = makeKeystore({}) as {
350
+ importApiKey: jest.Mock;
351
+ save: jest.Mock;
352
+ };
353
+ openKeystoreMock.mockResolvedValue(
354
+ succeed({
355
+ path: '/mock/keystore',
356
+ keystore: keystore as never
357
+ })
358
+ );
359
+ promptHiddenMock.mockResolvedValue(succeed('raw-secret'));
360
+
361
+ await new KsCli().run([
362
+ 'node',
363
+ 'ks',
364
+ 'export',
365
+ '--template-string',
366
+ 'export X={{missing}}',
367
+ '--encoding',
368
+ 'base64',
369
+ '--persist-missing'
370
+ ]);
371
+
372
+ // Template substitution sees the encoded value.
373
+ expect(consoleLogSpy).toHaveBeenCalledWith("export X='cmF3LXNlY3JldA=='");
374
+ // But the keystore receives the raw, unencoded value — never the base64 form.
375
+ expect(keystore.importApiKey).toHaveBeenCalledWith('missing', 'raw-secret', { replace: true });
376
+ expect(keystore.importApiKey).not.toHaveBeenCalledWith('missing', 'cmF3LXNlY3JldA==', expect.anything());
377
+ });
378
+ });
@@ -0,0 +1,50 @@
1
+ import '@fgv/ts-utils-jest';
2
+
3
+ import { encodeSecret, ENCODINGS, parseEncoding } from '../../src/encoding';
4
+
5
+ describe('encoding', () => {
6
+ describe('parseEncoding', () => {
7
+ test('returns the default encoding when undefined', () => {
8
+ expect(parseEncoding(undefined)).toSucceedWith('text');
9
+ });
10
+
11
+ test.each(ENCODINGS)('accepts %s', (value) => {
12
+ expect(parseEncoding(value)).toSucceedWith(value);
13
+ });
14
+
15
+ test('normalizes case', () => {
16
+ expect(parseEncoding('BASE64')).toSucceedWith('base64');
17
+ expect(parseEncoding('Hex')).toSucceedWith('hex');
18
+ });
19
+
20
+ test('rejects unknown values', () => {
21
+ expect(parseEncoding('utf16')).toFailWith(/invalid encoding 'utf16'/i);
22
+ });
23
+ });
24
+
25
+ describe('encodeSecret', () => {
26
+ test('returns text values unchanged', () => {
27
+ expect(encodeSecret('hello world', 'text')).toBe('hello world');
28
+ });
29
+
30
+ test('base64-encodes the UTF-8 bytes (with padding)', () => {
31
+ expect(encodeSecret('hello', 'base64')).toBe('aGVsbG8=');
32
+ });
33
+
34
+ test('hex-encodes the UTF-8 bytes', () => {
35
+ expect(encodeSecret('hello', 'hex')).toBe('68656c6c6f');
36
+ });
37
+
38
+ test('preserves multi-byte UTF-8 characters round-trip via base64', () => {
39
+ const original = 'café-✓-naïve';
40
+ const encoded = encodeSecret(original, 'base64');
41
+ expect(Buffer.from(encoded, 'base64').toString('utf8')).toBe(original);
42
+ });
43
+
44
+ test('preserves multi-byte UTF-8 characters round-trip via hex', () => {
45
+ const original = 'café-✓-naïve';
46
+ const encoded = encodeSecret(original, 'hex');
47
+ expect(Buffer.from(encoded, 'hex').toString('utf8')).toBe(original);
48
+ });
49
+ });
50
+ });