@trayio/cdk-cli 4.104.0 → 5.6.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.
Files changed (52) hide show
  1. package/OAUTH2_TOKEN.md +290 -0
  2. package/README.md +38 -1
  3. package/dist/commands/connector/oauth2-token.d.ts +45 -0
  4. package/dist/commands/connector/oauth2-token.d.ts.map +1 -0
  5. package/dist/commands/connector/oauth2-token.js +243 -0
  6. package/dist/commands/connector/oauth2-token.unit.test.d.ts +2 -0
  7. package/dist/commands/connector/oauth2-token.unit.test.d.ts.map +1 -0
  8. package/dist/commands/connector/oauth2-token.unit.test.js +1244 -0
  9. package/dist/lib/oauth2-token/flows/authorization-code.d.ts +4 -0
  10. package/dist/lib/oauth2-token/flows/authorization-code.d.ts.map +1 -0
  11. package/dist/lib/oauth2-token/flows/authorization-code.js +218 -0
  12. package/dist/lib/oauth2-token/flows/client-credentials.d.ts +4 -0
  13. package/dist/lib/oauth2-token/flows/client-credentials.d.ts.map +1 -0
  14. package/dist/lib/oauth2-token/flows/client-credentials.js +55 -0
  15. package/dist/lib/oauth2-token/flows/device-code.d.ts +4 -0
  16. package/dist/lib/oauth2-token/flows/device-code.d.ts.map +1 -0
  17. package/dist/lib/oauth2-token/flows/device-code.js +143 -0
  18. package/dist/lib/oauth2-token/flows/index.d.ts +8 -0
  19. package/dist/lib/oauth2-token/flows/index.d.ts.map +1 -0
  20. package/dist/lib/oauth2-token/flows/index.js +14 -0
  21. package/dist/lib/oauth2-token/flows/refresh-token.d.ts +4 -0
  22. package/dist/lib/oauth2-token/flows/refresh-token.d.ts.map +1 -0
  23. package/dist/lib/oauth2-token/flows/refresh-token.js +60 -0
  24. package/dist/lib/oauth2-token/token-writer.d.ts +7 -0
  25. package/dist/lib/oauth2-token/token-writer.d.ts.map +1 -0
  26. package/dist/lib/oauth2-token/token-writer.js +83 -0
  27. package/dist/lib/oauth2-token/types.d.ts +34 -0
  28. package/dist/lib/oauth2-token/types.d.ts.map +1 -0
  29. package/dist/lib/oauth2-token/types.js +5 -0
  30. package/dist/lib/oauth2-token/utils/browser.d.ts +2 -0
  31. package/dist/lib/oauth2-token/utils/browser.d.ts.map +1 -0
  32. package/dist/lib/oauth2-token/utils/browser.js +22 -0
  33. package/dist/lib/oauth2-token/utils/crypto.d.ts +6 -0
  34. package/dist/lib/oauth2-token/utils/crypto.d.ts.map +1 -0
  35. package/dist/lib/oauth2-token/utils/crypto.js +47 -0
  36. package/dist/lib/oauth2-token/utils/env.d.ts +7 -0
  37. package/dist/lib/oauth2-token/utils/env.d.ts.map +1 -0
  38. package/dist/lib/oauth2-token/utils/env.js +85 -0
  39. package/dist/lib/oauth2-token/utils/file.d.ts +4 -0
  40. package/dist/lib/oauth2-token/utils/file.d.ts.map +1 -0
  41. package/dist/lib/oauth2-token/utils/file.js +40 -0
  42. package/dist/lib/oauth2-token/utils/index.d.ts +10 -0
  43. package/dist/lib/oauth2-token/utils/index.d.ts.map +1 -0
  44. package/dist/lib/oauth2-token/utils/index.js +25 -0
  45. package/dist/lib/oauth2-token/utils/json.d.ts +9 -0
  46. package/dist/lib/oauth2-token/utils/json.d.ts.map +1 -0
  47. package/dist/lib/oauth2-token/utils/json.js +52 -0
  48. package/dist/lib/oauth2-token/utils/url.d.ts +6 -0
  49. package/dist/lib/oauth2-token/utils/url.d.ts.map +1 -0
  50. package/dist/lib/oauth2-token/utils/url.js +22 -0
  51. package/oclif.manifest.json +150 -1
  52. package/package.json +11 -9
@@ -0,0 +1,1244 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ const stdout_stderr_1 = require("stdout-stderr");
30
+ const core_1 = require("@oclif/core");
31
+ const fs = __importStar(require("fs/promises"));
32
+ const http = __importStar(require("http"));
33
+ const inquirer_1 = __importDefault(require("inquirer"));
34
+ // Note: open is mocked below, so we don't need to import it
35
+ const AxiosHttpClient_1 = require("@trayio/axios/http/AxiosHttpClient");
36
+ const BufferExtensions_1 = require("@trayio/commons/buffer/BufferExtensions");
37
+ const E = __importStar(require("fp-ts/Either"));
38
+ const child_process_1 = require("child_process");
39
+ const dotenv = __importStar(require("dotenv"));
40
+ const oauth2_token_1 = __importDefault(require("./oauth2-token"));
41
+ // Mock all external dependencies
42
+ jest.mock('fs/promises');
43
+ jest.mock('inquirer');
44
+ jest.mock('child_process');
45
+ jest.mock('chalk', () => {
46
+ const mockChalk = jest.fn((text) => text);
47
+ mockChalk.bold = jest.fn((text) => text);
48
+ mockChalk.gray = jest.fn((text) => text);
49
+ mockChalk.green = jest.fn((text) => text);
50
+ mockChalk.red = jest.fn((text) => text);
51
+ mockChalk.yellow = jest.fn((text) => text);
52
+ mockChalk.blue = jest.fn((text) => text);
53
+ mockChalk.cyan = jest.fn((text) => text);
54
+ mockChalk.magenta = jest.fn((text) => text);
55
+ mockChalk.white = jest.fn((text) => text);
56
+ mockChalk.black = jest.fn((text) => text);
57
+ mockChalk.dim = jest.fn((text) => text);
58
+ mockChalk.underline = jest.fn((text) => text);
59
+ mockChalk.inverse = jest.fn((text) => text);
60
+ mockChalk.strikethrough = jest.fn((text) => text);
61
+ return mockChalk;
62
+ });
63
+ jest.mock('@trayio/axios/http/AxiosHttpClient');
64
+ jest.mock('@trayio/commons/buffer/BufferExtensions');
65
+ jest.mock('http');
66
+ jest.mock('dotenv');
67
+ jest.mock('crypto', () => ({
68
+ ...jest.requireActual('crypto'),
69
+ randomBytes: jest
70
+ .fn()
71
+ .mockReturnValue(Buffer.from('mock-random-bytes-for-testing-purposes')),
72
+ }));
73
+ describe('OAuth2Token', () => {
74
+ const mockFs = fs;
75
+ const mockInquirer = inquirer_1.default;
76
+ const mockExec = jest.mocked(child_process_1.exec);
77
+ const mockAxiosHttpClient = AxiosHttpClient_1.AxiosHttpClient;
78
+ const mockBufferExtensions = BufferExtensions_1.BufferExtensions;
79
+ const mockHttp = http;
80
+ const mockDotenv = dotenv;
81
+ // Helper function to create proper ArrayBuffer from JSON
82
+ const createArrayBufferFromJson = (obj) => {
83
+ const jsonString = JSON.stringify(obj);
84
+ const buffer = Buffer.from(jsonString, 'utf-8');
85
+ return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
86
+ };
87
+ // Sample test context data
88
+ const mockContext = {
89
+ client_id: 'test-client-id',
90
+ client_secret: 'test-client-secret',
91
+ token_url: 'https://oauth.example.com/token',
92
+ authorize_url: 'https://oauth.example.com/authorize',
93
+ scope: 'read write',
94
+ };
95
+ // Sample token response
96
+ const mockTokenResponse = {
97
+ access_token: 'test-access-token',
98
+ refresh_token: 'test-refresh-token',
99
+ expires_in: 3600,
100
+ token_type: 'Bearer',
101
+ };
102
+ beforeEach(() => {
103
+ jest.clearAllMocks();
104
+ // Default file system mocks
105
+ mockFs.readFile.mockResolvedValue(JSON.stringify(mockContext));
106
+ mockFs.writeFile.mockResolvedValue();
107
+ mockFs.access.mockResolvedValue(undefined); // .env file exists by default
108
+ // Default dotenv mock
109
+ mockDotenv.config.mockReturnValue({
110
+ parsed: {},
111
+ error: undefined,
112
+ });
113
+ // Mock BufferExtensions - readableToArrayBuffer returns a TaskEither (function that returns Promise)
114
+ mockBufferExtensions.readableToArrayBuffer.mockImplementation(() => () => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
115
+ // Default HTTP client mock - execute returns a TaskEither (function that returns Promise)
116
+ const mockHttpInstance = {
117
+ execute: jest.fn().mockReturnValue(() => Promise.resolve(E.right({
118
+ statusCode: 200,
119
+ headers: {},
120
+ body: {}, // Readable stream mock
121
+ }))),
122
+ };
123
+ mockAxiosHttpClient.mockImplementation(() => mockHttpInstance);
124
+ // Default exec mock - return a mock ChildProcess
125
+ mockExec.mockImplementation(() => ({}));
126
+ });
127
+ afterAll(() => {
128
+ jest.restoreAllMocks();
129
+ });
130
+ describe('Client Credentials Flow', () => {
131
+ it('should successfully request token with client credentials', async () => {
132
+ const startSpy = jest.spyOn(core_1.ux.action, 'start');
133
+ const stopSpy = jest.spyOn(core_1.ux.action, 'stop');
134
+ stdout_stderr_1.stdout.start();
135
+ await oauth2_token_1.default.run([
136
+ '--ctxPath',
137
+ 'test.ctx.json',
138
+ '--grantType',
139
+ 'client_credentials',
140
+ ]);
141
+ stdout_stderr_1.stdout.stop();
142
+ expect(startSpy).toHaveBeenCalledWith('Requesting OAuth2 token (client_credentials)');
143
+ expect(stopSpy).toHaveBeenCalledWith(expect.stringContaining('done'));
144
+ expect(stdout_stderr_1.stdout.output).toContain('Access token written to test context file');
145
+ // Verify file was written with token
146
+ expect(mockFs.writeFile).toHaveBeenCalledWith(expect.stringContaining('test.ctx.json'), expect.stringContaining(mockTokenResponse.access_token), 'utf-8');
147
+ });
148
+ it('should handle missing required fields for client credentials', async () => {
149
+ mockFs.readFile.mockResolvedValue(JSON.stringify({}));
150
+ const command = new oauth2_token_1.default(['--grantType', 'client_credentials'], {});
151
+ await expect(command.run()).rejects.toThrow();
152
+ });
153
+ it('should include scope and audience in request when provided', async () => {
154
+ const mockExecute = jest.fn().mockReturnValue(() => Promise.resolve(E.right({
155
+ statusCode: 200,
156
+ headers: {},
157
+ body: {},
158
+ })));
159
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
160
+ await oauth2_token_1.default.run([
161
+ '--grantType',
162
+ 'client_credentials',
163
+ '--scope',
164
+ 'custom:scope',
165
+ '--audience',
166
+ 'https://api.example.com',
167
+ ]);
168
+ const requestBody = mockExecute.mock.calls[0][2].body.toString();
169
+ expect(requestBody).toContain('scope=custom%3Ascope');
170
+ expect(requestBody).toContain('audience=https%3A%2F%2Fapi.example.com');
171
+ });
172
+ });
173
+ describe('Authorization Code Flow', () => {
174
+ beforeEach(() => {
175
+ // Mock HTTP server for callback
176
+ const mockServer = {
177
+ listen: jest.fn().mockImplementation((port, host, callback) => {
178
+ callback?.();
179
+ }),
180
+ close: jest.fn().mockImplementation((callback) => {
181
+ callback?.();
182
+ }),
183
+ };
184
+ mockHttp.createServer.mockImplementation((requestHandler) => {
185
+ // Simulate successful callback - capture the actual state from the authorization URL
186
+ setTimeout(() => {
187
+ // With mocked crypto.randomBytes, the state will be predictable
188
+ // The state is base64url encoded from the mocked random bytes
189
+ const mockReq = {
190
+ url: '/callback?code=test-auth-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
191
+ };
192
+ const mockRes = {
193
+ writeHead: jest.fn(),
194
+ end: jest.fn(),
195
+ };
196
+ requestHandler?.(mockReq, mockRes);
197
+ }, 100);
198
+ return mockServer;
199
+ });
200
+ });
201
+ it('should successfully complete authorization code flow', async () => {
202
+ const startSpy = jest.spyOn(core_1.ux.action, 'start');
203
+ const stopSpy = jest.spyOn(core_1.ux.action, 'stop');
204
+ stdout_stderr_1.stdout.start();
205
+ await oauth2_token_1.default.run([
206
+ '--grantType',
207
+ 'authorization_code',
208
+ '--openBrowser',
209
+ ]);
210
+ stdout_stderr_1.stdout.stop();
211
+ expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('oauth.example.com/authorize'));
212
+ expect(startSpy).toHaveBeenCalledWith('Exchanging code for tokens');
213
+ expect(stopSpy).toHaveBeenCalledWith(expect.stringContaining('done'));
214
+ expect(stdout_stderr_1.stdout.output).toContain('Opening browser for authorization');
215
+ expect(stdout_stderr_1.stdout.output).toContain('Access token written to test context file');
216
+ });
217
+ it('should not open browser when openBrowser is disabled', async () => {
218
+ // Test with client_credentials flow which doesn't open browser
219
+ stdout_stderr_1.stdout.start();
220
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
221
+ stdout_stderr_1.stdout.stop();
222
+ expect(mockExec).not.toHaveBeenCalled();
223
+ });
224
+ it('should handle non-localhost redirect URI with manual input', async () => {
225
+ const contextWithRemoteRedirect = {
226
+ ...mockContext,
227
+ redirect_uri: 'https://example.com/callback',
228
+ };
229
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithRemoteRedirect));
230
+ mockInquirer.prompt.mockResolvedValue({
231
+ pastedUrl: 'https://example.com/callback?code=test-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
232
+ });
233
+ stdout_stderr_1.stdout.start();
234
+ await oauth2_token_1.default.run(['--grantType', 'authorization_code']);
235
+ stdout_stderr_1.stdout.stop();
236
+ expect(mockInquirer.prompt).toHaveBeenCalledWith([
237
+ {
238
+ name: 'pastedUrl',
239
+ message: 'Paste the full redirect URL:',
240
+ type: 'input',
241
+ },
242
+ ]);
243
+ expect(stdout_stderr_1.stdout.output).toContain('Redirect URI is not localhost');
244
+ });
245
+ });
246
+ describe('Device Code Flow', () => {
247
+ const mockDeviceInit = {
248
+ device_code: 'test-device-code',
249
+ user_code: 'ABC-123',
250
+ verification_uri: 'https://oauth.example.com/device',
251
+ verification_uri_complete: 'https://oauth.example.com/device?user_code=ABC-123',
252
+ expires_in: 900,
253
+ interval: 5,
254
+ };
255
+ beforeEach(() => {
256
+ const mockExecute = jest
257
+ .fn()
258
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
259
+ statusCode: 200,
260
+ headers: {},
261
+ body: {},
262
+ })))
263
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
264
+ statusCode: 200,
265
+ headers: {},
266
+ body: {},
267
+ })));
268
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
269
+ // Reset and mock BufferExtensions for device code responses
270
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
271
+ mockBufferExtensions.readableToArrayBuffer
272
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson(mockDeviceInit))))
273
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
274
+ });
275
+ it('should successfully complete device code flow', async () => {
276
+ const contextWithDeviceCode = {
277
+ ...mockContext,
278
+ device_code_url: 'https://oauth.example.com/device',
279
+ };
280
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithDeviceCode));
281
+ const startSpy = jest.spyOn(core_1.ux.action, 'start');
282
+ const stopSpy = jest.spyOn(core_1.ux.action, 'stop');
283
+ stdout_stderr_1.stdout.start();
284
+ await oauth2_token_1.default.run(['--grantType', 'device_code']);
285
+ stdout_stderr_1.stdout.stop();
286
+ expect(startSpy).toHaveBeenCalledWith('Starting device authorization');
287
+ expect(startSpy).toHaveBeenCalledWith('Waiting for user authorization');
288
+ expect(stopSpy).toHaveBeenCalledWith(expect.stringContaining('done'));
289
+ expect(stdout_stderr_1.stdout.output).toContain('User code: ABC-123');
290
+ expect(stdout_stderr_1.stdout.output).toContain('Visit: https://oauth.example.com/device');
291
+ });
292
+ it('should open browser for device code flow when openBrowser is true', async () => {
293
+ const contextWithDeviceCode = {
294
+ ...mockContext,
295
+ device_code_url: 'https://oauth.example.com/device',
296
+ };
297
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithDeviceCode));
298
+ await oauth2_token_1.default.run(['--grantType', 'device_code', '--openBrowser']);
299
+ expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('https://oauth.example.com/device?user_code=ABC-123'));
300
+ });
301
+ });
302
+ describe('Auto Grant Type Detection', () => {
303
+ it('should auto-detect authorization_code when authorize_url is present', async () => {
304
+ // Mock HTTP server for callback
305
+ const mockServer = {
306
+ listen: jest
307
+ .fn()
308
+ .mockImplementation((port, host, callback) => callback?.()),
309
+ close: jest.fn().mockImplementation((callback) => callback?.()),
310
+ };
311
+ mockHttp.createServer.mockImplementation((requestHandler) => {
312
+ setTimeout(() => {
313
+ const mockReq = {
314
+ url: '/callback?code=test-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
315
+ };
316
+ const mockRes = { writeHead: jest.fn(), end: jest.fn() };
317
+ requestHandler?.(mockReq, mockRes);
318
+ }, 100);
319
+ return mockServer;
320
+ });
321
+ stdout_stderr_1.stdout.start();
322
+ await oauth2_token_1.default.run(['--grantType', 'auto']);
323
+ stdout_stderr_1.stdout.stop();
324
+ expect(stdout_stderr_1.stdout.output).toContain('Opening browser for authorization');
325
+ });
326
+ it('should auto-detect device_code when device_code_url is present', async () => {
327
+ const contextWithDeviceCode = {
328
+ ...mockContext,
329
+ device_code_url: 'https://oauth.example.com/device',
330
+ authorize_url: undefined, // Remove authorize_url to prefer device code
331
+ };
332
+ delete contextWithDeviceCode.authorize_url;
333
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithDeviceCode));
334
+ const mockExecute = jest
335
+ .fn()
336
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
337
+ statusCode: 200,
338
+ headers: {},
339
+ body: {},
340
+ })))
341
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
342
+ statusCode: 200,
343
+ headers: {},
344
+ body: {},
345
+ })));
346
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
347
+ // Mock BufferExtensions for this test
348
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
349
+ mockBufferExtensions.readableToArrayBuffer
350
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
351
+ device_code: 'test-device-code',
352
+ user_code: 'ABC-123',
353
+ verification_uri: 'https://oauth.example.com/device',
354
+ expires_in: 900,
355
+ interval: 5,
356
+ }))))
357
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
358
+ stdout_stderr_1.stdout.start();
359
+ await oauth2_token_1.default.run(['--grantType', 'auto']);
360
+ stdout_stderr_1.stdout.stop();
361
+ expect(stdout_stderr_1.stdout.output).toContain('User code: ABC-123');
362
+ });
363
+ it('should default to client_credentials when no other flow is detected', async () => {
364
+ const contextWithoutFlowUrls = {
365
+ client_id: 'test-client-id',
366
+ client_secret: 'test-client-secret',
367
+ token_url: 'https://oauth.example.com/token',
368
+ };
369
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithoutFlowUrls));
370
+ const startSpy = jest.spyOn(core_1.ux.action, 'start');
371
+ await oauth2_token_1.default.run(['--grantType', 'auto']);
372
+ expect(startSpy).toHaveBeenCalledWith('Requesting OAuth2 token (client_credentials)');
373
+ });
374
+ });
375
+ describe('Dry Run Mode', () => {
376
+ it('should output token JSON without writing to file in dry run mode', async () => {
377
+ stdout_stderr_1.stdout.start();
378
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials', '--dryRun']);
379
+ stdout_stderr_1.stdout.stop();
380
+ expect(stdout_stderr_1.stdout.output).toContain(mockTokenResponse.access_token);
381
+ expect(stdout_stderr_1.stdout.output).toContain('test-refresh-token');
382
+ expect(mockFs.writeFile).not.toHaveBeenCalled();
383
+ });
384
+ });
385
+ describe('Error Handling', () => {
386
+ it('should handle HTTP client errors gracefully', async () => {
387
+ const mockError = new Error('Network error');
388
+ const mockExecute = jest
389
+ .fn()
390
+ .mockReturnValue(() => Promise.resolve(E.left(mockError)));
391
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
392
+ const command = new oauth2_token_1.default(['--grantType', 'client_credentials'], {});
393
+ await expect(command.run()).rejects.toThrow();
394
+ });
395
+ it('should handle missing access_token in response', async () => {
396
+ const invalidTokenResponse = { error: 'invalid_grant' };
397
+ const mockExecute = jest.fn().mockReturnValue(() => Promise.resolve(E.right({
398
+ statusCode: 400,
399
+ headers: {},
400
+ body: {},
401
+ })));
402
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
403
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
404
+ mockBufferExtensions.readableToArrayBuffer.mockReturnValue(() => Promise.resolve(E.right(createArrayBufferFromJson(invalidTokenResponse))));
405
+ const command = new oauth2_token_1.default(['--grantType', 'client_credentials'], {});
406
+ await expect(command.run()).rejects.toThrow('No access_token in token response');
407
+ });
408
+ it('should handle file read errors', async () => {
409
+ mockFs.readFile.mockRejectedValue(new Error('File not found'));
410
+ const command = new oauth2_token_1.default(['--ctxPath', 'nonexistent.json'], {});
411
+ await expect(command.run()).rejects.toThrow('File not found');
412
+ });
413
+ });
414
+ describe('Flag Overrides', () => {
415
+ it('should use flag overrides instead of context values', async () => {
416
+ const mockExecute = jest.fn().mockReturnValue(() => Promise.resolve(E.right({
417
+ statusCode: 200,
418
+ headers: {},
419
+ body: {},
420
+ })));
421
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
422
+ await oauth2_token_1.default.run([
423
+ '--grantType',
424
+ 'client_credentials',
425
+ '--clientId',
426
+ 'override-client-id',
427
+ '--clientSecret',
428
+ 'override-client-secret',
429
+ '--tokenUrl',
430
+ 'https://override.example.com/token',
431
+ ]);
432
+ const [method, url, request] = mockExecute.mock.calls[0];
433
+ const requestBody = request.body.toString();
434
+ expect(url).toBe('https://override.example.com/token');
435
+ expect(requestBody).toContain('client_id=override-client-id');
436
+ expect(requestBody).toContain('client_secret=override-client-secret');
437
+ });
438
+ });
439
+ describe('refresh token flow', () => {
440
+ let mockExecute;
441
+ beforeEach(() => {
442
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
443
+ client_id: 'test-client-id',
444
+ client_secret: 'test-client-secret',
445
+ token_url: 'https://example.com/token',
446
+ refresh_token: 'test-refresh-token',
447
+ }));
448
+ // Set up HTTP client mock for refresh tests
449
+ mockExecute = jest.fn();
450
+ const mockHttpInstance = {
451
+ execute: mockExecute,
452
+ };
453
+ mockAxiosHttpClient.mockImplementation(() => mockHttpInstance);
454
+ });
455
+ it('should refresh token successfully', async () => {
456
+ const refreshTokenResponse = {
457
+ access_token: 'new-access-token',
458
+ refresh_token: 'new-refresh-token',
459
+ expires_in: 3600,
460
+ token_type: 'Bearer',
461
+ };
462
+ mockExecute.mockReturnValue(() => Promise.resolve(E.right({
463
+ statusCode: 200,
464
+ headers: {},
465
+ body: {},
466
+ })));
467
+ mockBufferExtensions.readableToArrayBuffer.mockImplementation(() => () => Promise.resolve(E.right(createArrayBufferFromJson(refreshTokenResponse))));
468
+ await oauth2_token_1.default.run(['--refresh']);
469
+ expect(mockExecute).toHaveBeenCalledTimes(1);
470
+ const [method, url, request] = mockExecute.mock.calls[0];
471
+ const requestBody = request.body.toString();
472
+ expect(method).toBe('POST');
473
+ expect(url).toBe('https://example.com/token');
474
+ expect(requestBody).toContain('grant_type=refresh_token');
475
+ expect(requestBody).toContain('refresh_token=test-refresh-token');
476
+ expect(requestBody).toContain('client_id=test-client-id');
477
+ expect(requestBody).toContain('client_secret=test-client-secret');
478
+ expect(mockFs.writeFile).toHaveBeenCalledWith(expect.stringContaining('test.ctx.json'), expect.stringContaining('new-access-token'), 'utf-8');
479
+ });
480
+ it('should fail when no refresh token is found', async () => {
481
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
482
+ client_id: 'test-client-id',
483
+ client_secret: 'test-client-secret',
484
+ token_url: 'https://example.com/token',
485
+ // No refresh_token
486
+ }));
487
+ await expect(oauth2_token_1.default.run(['--refresh'])).rejects.toThrow('No refresh token found in context file');
488
+ });
489
+ it('should fail when token URL is missing', async () => {
490
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
491
+ client_id: 'test-client-id',
492
+ refresh_token: 'test-refresh-token',
493
+ // No token_url
494
+ }));
495
+ await expect(oauth2_token_1.default.run(['--refresh'])).rejects.toThrow('Unable to locate tokenUrl or clientId');
496
+ });
497
+ it('should work with dry run', async () => {
498
+ const dryRunTokenResponse = {
499
+ access_token: 'new-access-token',
500
+ refresh_token: 'new-refresh-token',
501
+ expires_in: 3600,
502
+ token_type: 'Bearer',
503
+ };
504
+ mockExecute.mockReturnValue(() => Promise.resolve(E.right({
505
+ statusCode: 200,
506
+ headers: {},
507
+ body: {},
508
+ })));
509
+ mockBufferExtensions.readableToArrayBuffer.mockImplementation(() => () => Promise.resolve(E.right(createArrayBufferFromJson(dryRunTokenResponse))));
510
+ stdout_stderr_1.stdout.start();
511
+ await oauth2_token_1.default.run(['--refresh', '--dryRun']);
512
+ stdout_stderr_1.stdout.stop();
513
+ expect(mockExecute).toHaveBeenCalledTimes(1);
514
+ expect(mockFs.writeFile).not.toHaveBeenCalled();
515
+ expect(stdout_stderr_1.stdout.output).toContain('new-access-token');
516
+ });
517
+ it('should include optional parameters when provided', async () => {
518
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
519
+ client_id: 'test-client-id',
520
+ client_secret: 'test-client-secret',
521
+ token_url: 'https://example.com/token',
522
+ refresh_token: 'test-refresh-token',
523
+ scope: 'read write',
524
+ audience: 'https://api.example.com',
525
+ }));
526
+ const scopeTokenResponse = {
527
+ access_token: 'new-access-token',
528
+ expires_in: 3600,
529
+ };
530
+ mockExecute.mockReturnValue(() => Promise.resolve(E.right({
531
+ statusCode: 200,
532
+ headers: {},
533
+ body: {},
534
+ })));
535
+ mockBufferExtensions.readableToArrayBuffer.mockImplementation(() => () => Promise.resolve(E.right(createArrayBufferFromJson(scopeTokenResponse))));
536
+ await oauth2_token_1.default.run(['--refresh']);
537
+ const [method, url, request] = mockExecute.mock.calls[0];
538
+ const requestBody = request.body.toString();
539
+ expect(requestBody).toContain('scope=read+write');
540
+ expect(requestBody).toContain('audience=https%3A%2F%2Fapi.example.com');
541
+ });
542
+ });
543
+ describe('URL utility functions', () => {
544
+ it('should handle invalid URL in isLocalhostRedirect', async () => {
545
+ // Test the error case where URL parsing fails
546
+ const contextWithInvalidRedirect = {
547
+ ...mockContext,
548
+ redirect_uri: 'not-a-valid-url',
549
+ };
550
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithInvalidRedirect));
551
+ // Mock inquirer to handle non-localhost redirect
552
+ mockInquirer.prompt.mockResolvedValue({
553
+ pastedUrl: 'https://example.com/callback?code=test-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
554
+ });
555
+ stdout_stderr_1.stdout.start();
556
+ await oauth2_token_1.default.run(['--grantType', 'authorization_code']);
557
+ stdout_stderr_1.stdout.stop();
558
+ // Invalid URL should trigger manual paste flow
559
+ expect(mockInquirer.prompt).toHaveBeenCalled();
560
+ expect(stdout_stderr_1.stdout.output).toContain('Redirect URI is not localhost');
561
+ });
562
+ });
563
+ describe('Browser utility functions', () => {
564
+ it('should handle Windows platform browser opening', async () => {
565
+ const originalPlatform = process.platform;
566
+ Object.defineProperty(process, 'platform', {
567
+ value: 'win32',
568
+ writable: true,
569
+ });
570
+ const contextWithDeviceCode = {
571
+ ...mockContext,
572
+ device_code_url: 'https://oauth.example.com/device',
573
+ };
574
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithDeviceCode));
575
+ const mockExecute = jest
576
+ .fn()
577
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
578
+ statusCode: 200,
579
+ headers: {},
580
+ body: {},
581
+ })))
582
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
583
+ statusCode: 200,
584
+ headers: {},
585
+ body: {},
586
+ })));
587
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
588
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
589
+ mockBufferExtensions.readableToArrayBuffer
590
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
591
+ device_code: 'test-device-code',
592
+ user_code: 'ABC-123',
593
+ verification_uri: 'https://oauth.example.com/device',
594
+ verification_uri_complete: 'https://oauth.example.com/device?user_code=ABC-123',
595
+ expires_in: 900,
596
+ interval: 5,
597
+ }))))
598
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
599
+ await oauth2_token_1.default.run(['--grantType', 'device_code', '--openBrowser']);
600
+ expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('start'));
601
+ Object.defineProperty(process, 'platform', {
602
+ value: originalPlatform,
603
+ writable: true,
604
+ });
605
+ });
606
+ it('should handle Linux platform browser opening', async () => {
607
+ const originalPlatform = process.platform;
608
+ Object.defineProperty(process, 'platform', {
609
+ value: 'linux',
610
+ writable: true,
611
+ });
612
+ const contextWithDeviceCode = {
613
+ ...mockContext,
614
+ device_code_url: 'https://oauth.example.com/device',
615
+ };
616
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithDeviceCode));
617
+ const mockExecute = jest
618
+ .fn()
619
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
620
+ statusCode: 200,
621
+ headers: {},
622
+ body: {},
623
+ })))
624
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
625
+ statusCode: 200,
626
+ headers: {},
627
+ body: {},
628
+ })));
629
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
630
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
631
+ mockBufferExtensions.readableToArrayBuffer
632
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
633
+ device_code: 'test-device-code',
634
+ user_code: 'ABC-123',
635
+ verification_uri: 'https://oauth.example.com/device',
636
+ verification_uri_complete: 'https://oauth.example.com/device?user_code=ABC-123',
637
+ expires_in: 900,
638
+ interval: 5,
639
+ }))))
640
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
641
+ await oauth2_token_1.default.run(['--grantType', 'device_code', '--openBrowser']);
642
+ expect(mockExec).toHaveBeenCalledWith(expect.stringContaining('xdg-open'));
643
+ Object.defineProperty(process, 'platform', {
644
+ value: originalPlatform,
645
+ writable: true,
646
+ });
647
+ });
648
+ });
649
+ describe('Environment variable error handling', () => {
650
+ it('should handle .env file read errors gracefully', async () => {
651
+ // Mock dotenv to throw an error
652
+ mockDotenv.config.mockReturnValue({
653
+ parsed: {},
654
+ error: new Error('Permission denied'),
655
+ });
656
+ // Should still work with context file
657
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
658
+ expect(mockAxiosHttpClient).toHaveBeenCalled();
659
+ });
660
+ it('should return empty config when .env parse fails', async () => {
661
+ mockDotenv.config.mockReturnValue({
662
+ parsed: undefined,
663
+ error: new Error('Parse error'),
664
+ });
665
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
666
+ expect(mockAxiosHttpClient).toHaveBeenCalled();
667
+ });
668
+ });
669
+ describe('Device Code Flow Error Handling', () => {
670
+ beforeEach(() => {
671
+ const contextWithDeviceCode = {
672
+ ...mockContext,
673
+ device_code_url: 'https://oauth.example.com/device',
674
+ };
675
+ mockFs.readFile.mockResolvedValue(JSON.stringify(contextWithDeviceCode));
676
+ });
677
+ it('should handle missing required parameters in device code flow', async () => {
678
+ const invalidContext = {
679
+ client_id: 'test-client-id',
680
+ token_url: 'https://oauth.example.com/token',
681
+ // Missing device_code_url
682
+ };
683
+ mockFs.readFile.mockResolvedValue(JSON.stringify(invalidContext));
684
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('Missing required parameters: tokenUrl, clientId, and deviceCodeUrl are required for device_code flow');
685
+ });
686
+ it('should handle HTTP client error during device initialization', async () => {
687
+ const mockError = new Error('Network error');
688
+ const mockExecute = jest
689
+ .fn()
690
+ .mockReturnValue(() => Promise.resolve(E.left(mockError)));
691
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
692
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('Network error');
693
+ });
694
+ it('should handle buffer read error during device initialization', async () => {
695
+ const mockExecute = jest.fn().mockReturnValue(() => Promise.resolve(E.right({
696
+ statusCode: 200,
697
+ headers: {},
698
+ body: {},
699
+ })));
700
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
701
+ const bufferError = new Error('Buffer read failed');
702
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
703
+ mockBufferExtensions.readableToArrayBuffer.mockReturnValue(() => Promise.resolve(E.left(bufferError)));
704
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('Buffer read failed');
705
+ });
706
+ it('should handle HTTP client error during device code polling', async () => {
707
+ const mockExecute = jest
708
+ .fn()
709
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
710
+ statusCode: 200,
711
+ headers: {},
712
+ body: {},
713
+ })))
714
+ .mockReturnValueOnce(() => Promise.resolve(E.left(new Error('Network error during poll'))));
715
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
716
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
717
+ mockBufferExtensions.readableToArrayBuffer.mockReturnValue(() => Promise.resolve(E.right(createArrayBufferFromJson({
718
+ device_code: 'test-device-code',
719
+ user_code: 'ABC-123',
720
+ verification_uri: 'https://oauth.example.com/device',
721
+ expires_in: 900,
722
+ interval: 1,
723
+ }))));
724
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('Network error during poll');
725
+ });
726
+ it('should handle slow_down error from device code poll', async () => {
727
+ const mockExecute = jest
728
+ .fn()
729
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
730
+ statusCode: 200,
731
+ headers: {},
732
+ body: {},
733
+ })))
734
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
735
+ statusCode: 400,
736
+ headers: {},
737
+ body: {},
738
+ })))
739
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
740
+ statusCode: 200,
741
+ headers: {},
742
+ body: {},
743
+ })));
744
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
745
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
746
+ mockBufferExtensions.readableToArrayBuffer
747
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
748
+ device_code: 'test-device-code',
749
+ user_code: 'ABC-123',
750
+ verification_uri: 'https://oauth.example.com/device',
751
+ expires_in: 900,
752
+ interval: 1,
753
+ }))))
754
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
755
+ error: 'slow_down',
756
+ }))))
757
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
758
+ await oauth2_token_1.default.run(['--grantType', 'device_code']);
759
+ expect(mockExecute).toHaveBeenCalledTimes(3);
760
+ });
761
+ it('should handle access_denied error from device code poll', async () => {
762
+ const mockExecute = jest
763
+ .fn()
764
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
765
+ statusCode: 200,
766
+ headers: {},
767
+ body: {},
768
+ })))
769
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
770
+ statusCode: 403,
771
+ headers: {},
772
+ body: {},
773
+ })));
774
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
775
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
776
+ mockBufferExtensions.readableToArrayBuffer
777
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
778
+ device_code: 'test-device-code',
779
+ user_code: 'ABC-123',
780
+ verification_uri: 'https://oauth.example.com/device',
781
+ expires_in: 900,
782
+ interval: 1,
783
+ }))))
784
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
785
+ error: 'access_denied',
786
+ }))));
787
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('User denied the request');
788
+ });
789
+ it('should handle expired_token error from device code poll', async () => {
790
+ const mockExecute = jest
791
+ .fn()
792
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
793
+ statusCode: 200,
794
+ headers: {},
795
+ body: {},
796
+ })))
797
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
798
+ statusCode: 400,
799
+ headers: {},
800
+ body: {},
801
+ })));
802
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
803
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
804
+ mockBufferExtensions.readableToArrayBuffer
805
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
806
+ device_code: 'test-device-code',
807
+ user_code: 'ABC-123',
808
+ verification_uri: 'https://oauth.example.com/device',
809
+ expires_in: 900,
810
+ interval: 1,
811
+ }))))
812
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
813
+ error: 'expired_token',
814
+ }))));
815
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('The device code has expired');
816
+ });
817
+ it('should handle expired error from device code poll', async () => {
818
+ const mockExecute = jest
819
+ .fn()
820
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
821
+ statusCode: 200,
822
+ headers: {},
823
+ body: {},
824
+ })))
825
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
826
+ statusCode: 400,
827
+ headers: {},
828
+ body: {},
829
+ })));
830
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
831
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
832
+ mockBufferExtensions.readableToArrayBuffer
833
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
834
+ device_code: 'test-device-code',
835
+ user_code: 'ABC-123',
836
+ verification_uri: 'https://oauth.example.com/device',
837
+ expires_in: 900,
838
+ interval: 1,
839
+ }))))
840
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
841
+ error: 'expired',
842
+ }))));
843
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('The device code has expired');
844
+ });
845
+ it('should handle unknown error from device code poll', async () => {
846
+ const mockExecute = jest
847
+ .fn()
848
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
849
+ statusCode: 200,
850
+ headers: {},
851
+ body: {},
852
+ })))
853
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
854
+ statusCode: 500,
855
+ headers: {},
856
+ body: {},
857
+ })))
858
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
859
+ statusCode: 200,
860
+ headers: {},
861
+ body: {},
862
+ })));
863
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
864
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
865
+ mockBufferExtensions.readableToArrayBuffer
866
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
867
+ device_code: 'test-device-code',
868
+ user_code: 'ABC-123',
869
+ verification_uri: 'https://oauth.example.com/device',
870
+ expires_in: 900,
871
+ interval: 1,
872
+ }))))
873
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
874
+ error: 'unknown_error',
875
+ }))))
876
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
877
+ await oauth2_token_1.default.run(['--grantType', 'device_code']);
878
+ expect(mockExecute).toHaveBeenCalledTimes(3);
879
+ });
880
+ it('should handle device code timeout', async () => {
881
+ const mockExecute = jest.fn().mockReturnValue(() => Promise.resolve(E.right({
882
+ statusCode: 400,
883
+ headers: {},
884
+ body: {},
885
+ })));
886
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
887
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
888
+ mockBufferExtensions.readableToArrayBuffer
889
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
890
+ device_code: 'test-device-code',
891
+ user_code: 'ABC-123',
892
+ verification_uri: 'https://oauth.example.com/device',
893
+ expires_in: 1,
894
+ interval: 1,
895
+ }))))
896
+ .mockReturnValue(() => Promise.resolve(E.right(createArrayBufferFromJson({
897
+ error: 'authorization_pending',
898
+ }))));
899
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('Timed out waiting for device authorization');
900
+ });
901
+ it('should handle buffer read error during device code polling', async () => {
902
+ const mockExecute = jest
903
+ .fn()
904
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
905
+ statusCode: 200,
906
+ headers: {},
907
+ body: {},
908
+ })))
909
+ .mockReturnValueOnce(() => Promise.resolve(E.right({
910
+ statusCode: 200,
911
+ headers: {},
912
+ body: {},
913
+ })));
914
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
915
+ const bufferError = new Error('Buffer read failed');
916
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
917
+ mockBufferExtensions.readableToArrayBuffer
918
+ .mockReturnValueOnce(() => Promise.resolve(E.right(createArrayBufferFromJson({
919
+ device_code: 'test-device-code',
920
+ user_code: 'ABC-123',
921
+ verification_uri: 'https://oauth.example.com/device',
922
+ expires_in: 900,
923
+ interval: 1,
924
+ }))))
925
+ .mockReturnValueOnce(() => Promise.resolve(E.left(bufferError)));
926
+ await expect(oauth2_token_1.default.run(['--grantType', 'device_code'])).rejects.toThrow('Buffer read failed');
927
+ });
928
+ });
929
+ describe('Authorization Code Flow Error Handling', () => {
930
+ beforeEach(() => {
931
+ // Mock HTTP server for callback
932
+ const mockServer = {
933
+ listen: jest
934
+ .fn()
935
+ .mockImplementation((port, host, callback) => callback?.()),
936
+ close: jest.fn().mockImplementation((callback) => callback?.()),
937
+ };
938
+ mockHttp.createServer.mockImplementation((requestHandler) => {
939
+ setTimeout(() => {
940
+ const mockReq = {
941
+ url: '/callback?code=test-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
942
+ };
943
+ const mockRes = { writeHead: jest.fn(), end: jest.fn() };
944
+ requestHandler?.(mockReq, mockRes);
945
+ }, 100);
946
+ return mockServer;
947
+ });
948
+ });
949
+ it('should handle buffer read error during token exchange', async () => {
950
+ const mockExecute = jest.fn().mockReturnValue(() => Promise.resolve(E.right({
951
+ statusCode: 200,
952
+ headers: {},
953
+ body: {},
954
+ })));
955
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
956
+ const bufferError = new Error('Buffer read failed');
957
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
958
+ mockBufferExtensions.readableToArrayBuffer.mockReturnValue(() => Promise.resolve(E.left(bufferError)));
959
+ await expect(oauth2_token_1.default.run(['--grantType', 'authorization_code'])).rejects.toThrow('Buffer read failed');
960
+ });
961
+ it('should handle missing access token in authorization code response', async () => {
962
+ const mockExecute = jest.fn().mockReturnValue(() => Promise.resolve(E.right({
963
+ statusCode: 200,
964
+ headers: {},
965
+ body: {},
966
+ })));
967
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
968
+ mockBufferExtensions.readableToArrayBuffer.mockReset();
969
+ mockBufferExtensions.readableToArrayBuffer.mockReturnValue(() => Promise.resolve(E.right(createArrayBufferFromJson({
970
+ error: 'invalid_grant',
971
+ }))));
972
+ await expect(oauth2_token_1.default.run(['--grantType', 'authorization_code'])).rejects.toThrow('No access_token in token response');
973
+ });
974
+ it('should handle missing tokenUrl in authorization code flow', async () => {
975
+ const invalidContext = {
976
+ client_id: 'test-client-id',
977
+ authorize_url: 'https://oauth.example.com/authorize',
978
+ // Missing token_url
979
+ };
980
+ mockFs.readFile.mockResolvedValue(JSON.stringify(invalidContext));
981
+ const mockServer = {
982
+ listen: jest
983
+ .fn()
984
+ .mockImplementation((port, host, callback) => callback?.()),
985
+ close: jest.fn().mockImplementation((callback) => callback?.()),
986
+ };
987
+ mockHttp.createServer.mockImplementation((requestHandler) => {
988
+ setTimeout(() => {
989
+ const mockReq = {
990
+ url: '/callback?code=test-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
991
+ };
992
+ const mockRes = { writeHead: jest.fn(), end: jest.fn() };
993
+ requestHandler?.(mockReq, mockRes);
994
+ }, 100);
995
+ return mockServer;
996
+ });
997
+ await expect(oauth2_token_1.default.run(['--grantType', 'authorization_code'])).rejects.toThrow('Missing required parameters: tokenUrl, clientId, and authorizeUrl are required for authorization_code flow');
998
+ });
999
+ it('should handle invalid tokenUrl in authorization code flow', async () => {
1000
+ const invalidContext = {
1001
+ ...mockContext,
1002
+ token_url: 'not-a-valid-url',
1003
+ };
1004
+ mockFs.readFile.mockResolvedValue(JSON.stringify(invalidContext));
1005
+ const mockServer = {
1006
+ listen: jest
1007
+ .fn()
1008
+ .mockImplementation((port, host, callback) => callback?.()),
1009
+ close: jest.fn().mockImplementation((callback) => callback?.()),
1010
+ };
1011
+ mockHttp.createServer.mockImplementation((requestHandler) => {
1012
+ setTimeout(() => {
1013
+ const mockReq = {
1014
+ url: '/callback?code=test-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
1015
+ };
1016
+ const mockRes = { writeHead: jest.fn(), end: jest.fn() };
1017
+ requestHandler?.(mockReq, mockRes);
1018
+ }, 100);
1019
+ return mockServer;
1020
+ });
1021
+ await expect(oauth2_token_1.default.run(['--grantType', 'authorization_code'])).rejects.toThrow('Invalid tokenUrl');
1022
+ });
1023
+ it('should handle invalid authorizeUrl in authorization code flow', async () => {
1024
+ const invalidContext = {
1025
+ ...mockContext,
1026
+ authorize_url: 'not-a-valid-url',
1027
+ };
1028
+ mockFs.readFile.mockResolvedValue(JSON.stringify(invalidContext));
1029
+ const mockServer = {
1030
+ listen: jest
1031
+ .fn()
1032
+ .mockImplementation((port, host, callback) => callback?.()),
1033
+ close: jest.fn().mockImplementation((callback) => callback?.()),
1034
+ };
1035
+ mockHttp.createServer.mockImplementation((requestHandler) => {
1036
+ setTimeout(() => {
1037
+ const mockReq = {
1038
+ url: '/callback?code=test-code&state=bW9jay1yYW5kb20tYnl0ZXMtZm9yLXRlc3RpbmctcHVycG9zZXM',
1039
+ };
1040
+ const mockRes = { writeHead: jest.fn(), end: jest.fn() };
1041
+ requestHandler?.(mockReq, mockRes);
1042
+ }, 100);
1043
+ return mockServer;
1044
+ });
1045
+ await expect(oauth2_token_1.default.run(['--grantType', 'authorization_code'])).rejects.toThrow('Invalid authorizeUrl');
1046
+ });
1047
+ it('should handle state mismatch error in authorization code callback', async () => {
1048
+ const mockServer = {
1049
+ listen: jest
1050
+ .fn()
1051
+ .mockImplementation((port, host, callback) => callback?.()),
1052
+ close: jest.fn().mockImplementation((callback) => callback?.()),
1053
+ };
1054
+ mockHttp.createServer.mockImplementation((requestHandler) => {
1055
+ setTimeout(() => {
1056
+ const mockReq = {
1057
+ url: '/callback?code=test-code&state=wrong-state',
1058
+ };
1059
+ const mockRes = { writeHead: jest.fn(), end: jest.fn() };
1060
+ requestHandler?.(mockReq, mockRes);
1061
+ }, 100);
1062
+ return mockServer;
1063
+ });
1064
+ await expect(oauth2_token_1.default.run(['--grantType', 'authorization_code'])).rejects.toThrow('State mismatch');
1065
+ });
1066
+ it('should handle HTTP client error during token exchange', async () => {
1067
+ const mockError = new Error('Network error');
1068
+ const mockExecute = jest
1069
+ .fn()
1070
+ .mockReturnValue(() => Promise.resolve(E.left(mockError)));
1071
+ mockAxiosHttpClient.mockImplementation(() => ({ execute: mockExecute }));
1072
+ await expect(oauth2_token_1.default.run(['--grantType', 'authorization_code'])).rejects.toThrow('Network error');
1073
+ });
1074
+ });
1075
+ describe('Environment variable support', () => {
1076
+ let mockExecute;
1077
+ beforeEach(() => {
1078
+ // Set up HTTP client mock for env tests
1079
+ mockExecute = jest.fn();
1080
+ const mockHttpInstance = {
1081
+ execute: mockExecute,
1082
+ };
1083
+ mockAxiosHttpClient.mockImplementation(() => mockHttpInstance);
1084
+ mockExecute.mockReturnValue(() => Promise.resolve(E.right({
1085
+ statusCode: 200,
1086
+ headers: {},
1087
+ body: {},
1088
+ })));
1089
+ mockBufferExtensions.readableToArrayBuffer.mockImplementation(() => () => Promise.resolve(E.right(createArrayBufferFromJson(mockTokenResponse))));
1090
+ });
1091
+ it('should read credentials from .env file with OAUTH2_ prefix', async () => {
1092
+ // Mock context file without sensitive data
1093
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
1094
+ token_url: 'https://example.com/token',
1095
+ }));
1096
+ // Mock .env file with OAUTH2_ prefixed variables
1097
+ mockDotenv.config.mockReturnValue({
1098
+ parsed: {
1099
+ OAUTH2_CLIENT_ID: 'env-client-id',
1100
+ OAUTH2_CLIENT_SECRET: 'env-client-secret',
1101
+ OAUTH2_SCOPE: 'read write',
1102
+ OAUTH2_AUDIENCE: 'https://api.example.com',
1103
+ },
1104
+ error: undefined,
1105
+ });
1106
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
1107
+ expect(mockExecute).toHaveBeenCalledTimes(1);
1108
+ const [method, url, request] = mockExecute.mock.calls[0];
1109
+ const requestBody = request.body.toString();
1110
+ expect(requestBody).toContain('client_id=env-client-id');
1111
+ expect(requestBody).toContain('client_secret=env-client-secret');
1112
+ expect(requestBody).toContain('scope=read+write');
1113
+ expect(requestBody).toContain('audience=https%3A%2F%2Fapi.example.com');
1114
+ });
1115
+ it('should read credentials from .env file without prefix', async () => {
1116
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
1117
+ token_url: 'https://example.com/token',
1118
+ }));
1119
+ // Mock .env file with unprefixed variables
1120
+ mockDotenv.config.mockReturnValue({
1121
+ parsed: {
1122
+ CLIENT_ID: 'env-client-id-no-prefix',
1123
+ CLIENT_SECRET: 'env-client-secret-no-prefix',
1124
+ },
1125
+ error: undefined,
1126
+ });
1127
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
1128
+ const [method, url, request] = mockExecute.mock.calls[0];
1129
+ const requestBody = request.body.toString();
1130
+ expect(requestBody).toContain('client_id=env-client-id-no-prefix');
1131
+ expect(requestBody).toContain('client_secret=env-client-secret-no-prefix');
1132
+ });
1133
+ it('should prioritize OAUTH2_ prefix over unprefixed variables', async () => {
1134
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
1135
+ token_url: 'https://example.com/token',
1136
+ }));
1137
+ mockDotenv.config.mockReturnValue({
1138
+ parsed: {
1139
+ OAUTH2_CLIENT_ID: 'prefixed-client-id',
1140
+ CLIENT_ID: 'unprefixed-client-id',
1141
+ OAUTH2_CLIENT_SECRET: 'prefixed-secret',
1142
+ CLIENT_SECRET: 'unprefixed-secret',
1143
+ },
1144
+ error: undefined,
1145
+ });
1146
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
1147
+ const [method, url, request] = mockExecute.mock.calls[0];
1148
+ const requestBody = request.body.toString();
1149
+ expect(requestBody).toContain('client_id=prefixed-client-id');
1150
+ expect(requestBody).toContain('client_secret=prefixed-secret');
1151
+ });
1152
+ it('should use correct precedence: flags > env > context', async () => {
1153
+ // Context file has some values
1154
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
1155
+ token_url: 'https://example.com/token',
1156
+ client_id: 'context-client-id',
1157
+ client_secret: 'context-client-secret',
1158
+ }));
1159
+ // .env file has some values
1160
+ mockDotenv.config.mockReturnValue({
1161
+ parsed: {
1162
+ OAUTH2_CLIENT_ID: 'env-client-id',
1163
+ OAUTH2_SCOPE: 'env-scope',
1164
+ },
1165
+ error: undefined,
1166
+ });
1167
+ // Flag overrides everything
1168
+ await oauth2_token_1.default.run([
1169
+ '--grantType',
1170
+ 'client_credentials',
1171
+ '--clientId',
1172
+ 'flag-client-id',
1173
+ ]);
1174
+ const [method, url, request] = mockExecute.mock.calls[0];
1175
+ const requestBody = request.body.toString();
1176
+ // Flag takes precedence over env and context
1177
+ expect(requestBody).toContain('client_id=flag-client-id');
1178
+ // Env takes precedence over context
1179
+ expect(requestBody).toContain('scope=env-scope');
1180
+ // Context used when not in flag or env
1181
+ expect(requestBody).toContain('client_secret=context-client-secret');
1182
+ });
1183
+ it('should work with refresh token from .env file', async () => {
1184
+ mockFs.readFile.mockResolvedValue(JSON.stringify({
1185
+ token_url: 'https://example.com/token',
1186
+ client_id: 'test-client-id',
1187
+ }));
1188
+ mockDotenv.config.mockReturnValue({
1189
+ parsed: {
1190
+ OAUTH2_REFRESH_TOKEN: 'env-refresh-token',
1191
+ OAUTH2_CLIENT_SECRET: 'env-client-secret',
1192
+ },
1193
+ error: undefined,
1194
+ });
1195
+ await oauth2_token_1.default.run(['--refresh']);
1196
+ const [method, url, request] = mockExecute.mock.calls[0];
1197
+ const requestBody = request.body.toString();
1198
+ expect(requestBody).toContain('grant_type=refresh_token');
1199
+ expect(requestBody).toContain('refresh_token=env-refresh-token');
1200
+ expect(requestBody).toContain('client_secret=env-client-secret');
1201
+ });
1202
+ it('should work when .env file does not exist', async () => {
1203
+ // Mock .env file not existing
1204
+ mockFs.access.mockRejectedValue(new Error('File not found'));
1205
+ mockDotenv.config.mockReturnValue({
1206
+ parsed: {},
1207
+ error: new Error('File not found'),
1208
+ });
1209
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
1210
+ // Should still work with context file values
1211
+ expect(mockExecute).toHaveBeenCalledTimes(1);
1212
+ });
1213
+ it('should use custom .env path when specified', async () => {
1214
+ await oauth2_token_1.default.run([
1215
+ '--grantType',
1216
+ 'client_credentials',
1217
+ '--envPath',
1218
+ 'custom/.env',
1219
+ ]);
1220
+ expect(mockDotenv.config).toHaveBeenCalledWith({
1221
+ path: expect.stringContaining('custom/.env'),
1222
+ });
1223
+ });
1224
+ it('should read all OAuth2 URLs from .env file', async () => {
1225
+ mockFs.readFile.mockResolvedValue(JSON.stringify({}));
1226
+ mockDotenv.config.mockReturnValue({
1227
+ parsed: {
1228
+ OAUTH2_TOKEN_URL: 'https://env.example.com/token',
1229
+ OAUTH2_AUTHORIZE_URL: 'https://env.example.com/authorize',
1230
+ OAUTH2_DEVICE_CODE_URL: 'https://env.example.com/device',
1231
+ OAUTH2_REDIRECT_URI: 'http://env.localhost:8080/callback',
1232
+ OAUTH2_CLIENT_ID: 'env-client-id',
1233
+ OAUTH2_CLIENT_SECRET: 'env-client-secret',
1234
+ },
1235
+ error: undefined,
1236
+ });
1237
+ await oauth2_token_1.default.run(['--grantType', 'client_credentials']);
1238
+ const [method, url, request] = mockExecute.mock.calls[0];
1239
+ const requestBody = request.body.toString();
1240
+ expect(url).toBe('https://env.example.com/token');
1241
+ expect(requestBody).toContain('client_id=env-client-id');
1242
+ });
1243
+ });
1244
+ });