tm1npm 1.5.3 → 2.0.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 (78) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/lib/index.d.ts +1 -1
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/services/ApplicationService.d.ts +19 -3
  5. package/lib/services/ApplicationService.d.ts.map +1 -1
  6. package/lib/services/ApplicationService.js +232 -6
  7. package/lib/services/AsyncOperationService.d.ts +8 -1
  8. package/lib/services/AsyncOperationService.d.ts.map +1 -1
  9. package/lib/services/AsyncOperationService.js +69 -26
  10. package/lib/services/ElementService.d.ts +67 -1
  11. package/lib/services/ElementService.d.ts.map +1 -1
  12. package/lib/services/ElementService.js +214 -0
  13. package/lib/services/FileService.d.ts.map +1 -1
  14. package/lib/services/HierarchyService.d.ts +26 -0
  15. package/lib/services/HierarchyService.d.ts.map +1 -1
  16. package/lib/services/HierarchyService.js +306 -0
  17. package/lib/services/ProcessService.d.ts +40 -22
  18. package/lib/services/ProcessService.d.ts.map +1 -1
  19. package/lib/services/ProcessService.js +118 -111
  20. package/lib/services/RestService.d.ts +213 -25
  21. package/lib/services/RestService.d.ts.map +1 -1
  22. package/lib/services/RestService.js +841 -263
  23. package/lib/services/SubsetService.d.ts +2 -0
  24. package/lib/services/SubsetService.d.ts.map +1 -1
  25. package/lib/services/SubsetService.js +33 -0
  26. package/lib/services/TM1Service.d.ts +44 -1
  27. package/lib/services/TM1Service.d.ts.map +1 -1
  28. package/lib/services/TM1Service.js +96 -4
  29. package/lib/services/index.d.ts +1 -1
  30. package/lib/services/index.d.ts.map +1 -1
  31. package/lib/tests/100PercentParityCheck.test.js +23 -6
  32. package/lib/tests/applicationService.issue38.test.d.ts +5 -0
  33. package/lib/tests/applicationService.issue38.test.d.ts.map +1 -0
  34. package/lib/tests/applicationService.issue38.test.js +237 -0
  35. package/lib/tests/asyncOperationService.test.js +51 -45
  36. package/lib/tests/bugfix28.test.js +12 -4
  37. package/lib/tests/elementService.issue37.test.d.ts +5 -0
  38. package/lib/tests/elementService.issue37.test.d.ts.map +1 -0
  39. package/lib/tests/elementService.issue37.test.js +413 -0
  40. package/lib/tests/elementService.issue38.test.d.ts +5 -0
  41. package/lib/tests/elementService.issue38.test.d.ts.map +1 -0
  42. package/lib/tests/elementService.issue38.test.js +79 -0
  43. package/lib/tests/hierarchyService.issue38.test.d.ts +5 -0
  44. package/lib/tests/hierarchyService.issue38.test.d.ts.map +1 -0
  45. package/lib/tests/hierarchyService.issue38.test.js +460 -0
  46. package/lib/tests/processService.comprehensive.test.js +9 -9
  47. package/lib/tests/processService.test.js +234 -0
  48. package/lib/tests/restService.test.d.ts +0 -4
  49. package/lib/tests/restService.test.d.ts.map +1 -1
  50. package/lib/tests/restService.test.js +1558 -143
  51. package/lib/tests/subsetService.issue38.test.d.ts +5 -0
  52. package/lib/tests/subsetService.issue38.test.d.ts.map +1 -0
  53. package/lib/tests/subsetService.issue38.test.js +113 -0
  54. package/lib/tests/tm1Service.test.js +80 -8
  55. package/package.json +1 -1
  56. package/src/index.ts +1 -1
  57. package/src/services/ApplicationService.ts +282 -10
  58. package/src/services/AsyncOperationService.ts +76 -29
  59. package/src/services/ElementService.ts +322 -1
  60. package/src/services/FileService.ts +3 -3
  61. package/src/services/HierarchyService.ts +419 -1
  62. package/src/services/ProcessService.ts +185 -142
  63. package/src/services/RestService.ts +1021 -267
  64. package/src/services/SubsetService.ts +48 -0
  65. package/src/services/TM1Service.ts +127 -6
  66. package/src/services/index.ts +1 -1
  67. package/src/tests/100PercentParityCheck.test.ts +29 -8
  68. package/src/tests/applicationService.issue38.test.ts +293 -0
  69. package/src/tests/asyncOperationService.test.ts +52 -48
  70. package/src/tests/bugfix28.test.ts +12 -4
  71. package/src/tests/elementService.issue37.test.ts +571 -0
  72. package/src/tests/elementService.issue38.test.ts +103 -0
  73. package/src/tests/hierarchyService.issue38.test.ts +599 -0
  74. package/src/tests/processService.comprehensive.test.ts +10 -10
  75. package/src/tests/processService.test.ts +295 -3
  76. package/src/tests/restService.test.ts +1844 -139
  77. package/src/tests/subsetService.issue38.test.ts +182 -0
  78. package/src/tests/tm1Service.test.ts +95 -11
@@ -1,218 +1,1923 @@
1
- /**
2
- * RestService Tests for tm1npm
3
- * Comprehensive tests for TM1 REST API operations with proper mocking
4
- */
5
-
6
- import { RestService } from '../services/RestService';
7
1
  import axios, { AxiosResponse } from 'axios';
2
+ import { RestService, AuthenticationMode } from '../services/RestService';
3
+ import { TM1RestException, TM1TimeoutException } from '../exceptions/TM1Exception';
8
4
 
9
- // Mock axios
10
5
  jest.mock('axios');
6
+
11
7
  const mockedAxios = axios as jest.Mocked<typeof axios>;
12
8
 
13
- // Helper function to create mock AxiosResponse
14
- const createMockResponse = (data: any, status: number = 200): AxiosResponse => ({
9
+ const createMockResponse = (data: any, status = 200, headers: Record<string, any> = {}): AxiosResponse => ({
15
10
  data,
16
11
  status,
17
- statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
18
- headers: {},
12
+ statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 202 ? 'Accepted' : 'Error',
13
+ headers,
19
14
  config: {} as any
20
15
  });
21
16
 
22
- describe('RestService Tests', () => {
17
+ describe('RestService', () => {
23
18
  let restService: RestService;
19
+ let mockAxiosInstance: any;
20
+ let responseErrorHandler: ((error: any) => Promise<any>) | undefined;
24
21
 
25
22
  beforeEach(() => {
26
- // Clear all mocks
27
23
  jest.clearAllMocks();
28
-
29
- // Mock axios.create
30
- const mockAxiosInstance = {
24
+ jest.useRealTimers();
25
+ responseErrorHandler = undefined;
26
+
27
+ mockAxiosInstance = Object.assign(jest.fn(), {
31
28
  get: jest.fn(),
32
29
  post: jest.fn(),
33
30
  patch: jest.fn(),
34
- delete: jest.fn(),
35
31
  put: jest.fn(),
32
+ delete: jest.fn(),
33
+ request: jest.fn(),
34
+ defaults: { headers: { common: {} } },
36
35
  interceptors: {
37
- request: { use: jest.fn() },
38
- response: { use: jest.fn() }
36
+ request: {
37
+ use: jest.fn()
38
+ },
39
+ response: {
40
+ use: jest.fn((onFulfilled: any, onRejected: any) => {
41
+ responseErrorHandler = onRejected;
42
+ return 0;
43
+ })
44
+ }
39
45
  }
40
- };
46
+ });
41
47
 
42
- mockedAxios.create.mockReturnValue(mockAxiosInstance as any);
43
-
44
- const config = {
48
+ mockedAxios.create.mockReturnValue(mockAxiosInstance);
49
+
50
+ restService = new RestService({
45
51
  baseUrl: 'http://localhost:8879/api/v1',
46
52
  user: 'admin',
47
53
  password: 'password',
48
- timeout: 30000
54
+ timeout: 60
55
+ });
56
+ });
57
+
58
+ test('routes sync GET requests through the central dispatcher', async () => {
59
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ value: '11.8.0' }));
60
+
61
+ const response = await restService.get('/Configuration/ProductVersion');
62
+
63
+ expect(response.data.value).toBe('11.8.0');
64
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
65
+ expect.objectContaining({
66
+ method: 'GET',
67
+ url: '/Configuration/ProductVersion',
68
+ timeout: 60000,
69
+ _idempotent: true
70
+ })
71
+ );
72
+ });
73
+
74
+ test('routes async requests with Prefer header and polls /_async endpoint', async () => {
75
+ mockAxiosInstance.request
76
+ .mockResolvedValueOnce(createMockResponse({}, 202, {
77
+ location: "/api/v1/_async('async-001')"
78
+ }))
79
+ .mockResolvedValueOnce(createMockResponse({ done: true }, 200));
80
+
81
+ const response = await restService.get('/Threads', { asyncRequestsMode: true });
82
+
83
+ expect(response.data.done).toBe(true);
84
+ expect(mockAxiosInstance.request).toHaveBeenNthCalledWith(1,
85
+ expect.objectContaining({
86
+ method: 'GET',
87
+ url: '/Threads',
88
+ headers: expect.objectContaining({
89
+ Prefer: 'respond-async,wait=55'
90
+ })
91
+ })
92
+ );
93
+ expect(mockAxiosInstance.request).toHaveBeenNthCalledWith(2,
94
+ expect.objectContaining({
95
+ method: 'GET',
96
+ url: "/_async('async-001')"
97
+ })
98
+ );
99
+ });
100
+
101
+ test('returns async ID when returnAsyncId is true', async () => {
102
+ mockAxiosInstance.request.mockResolvedValue(
103
+ createMockResponse({}, 202, {
104
+ location: "/api/v1/_async('async-123')"
105
+ })
106
+ );
107
+
108
+ const asyncId = await restService.post('/Processes', {}, { returnAsyncId: true });
109
+
110
+ expect(asyncId).toBe('async-123');
111
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
112
+ expect.objectContaining({
113
+ headers: expect.objectContaining({
114
+ Prefer: 'respond-async'
115
+ })
116
+ })
117
+ );
118
+ });
119
+
120
+ test('uses per-request asyncRequestsMode over the instance default', async () => {
121
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ ok: true }));
122
+
123
+ await restService.get('/test', { asyncRequestsMode: true });
124
+
125
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
126
+ expect.objectContaining({
127
+ headers: expect.objectContaining({
128
+ Prefer: 'respond-async,wait=55'
129
+ })
130
+ })
131
+ );
132
+ });
133
+
134
+ test('uses per-request timeout in seconds', async () => {
135
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ ok: true }));
136
+
137
+ await restService.post('/test', {}, { timeout: 10 });
138
+
139
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
140
+ expect.objectContaining({
141
+ timeout: 10000
142
+ })
143
+ );
144
+ });
145
+
146
+ test('passes responseType and custom headers through to Axios', async () => {
147
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse(Buffer.from('abc')));
148
+
149
+ await restService.get('/files/test', {
150
+ responseType: 'arraybuffer',
151
+ headers: {
152
+ 'X-Test': 'value'
153
+ }
154
+ });
155
+
156
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
157
+ expect.objectContaining({
158
+ responseType: 'arraybuffer',
159
+ headers: expect.objectContaining({
160
+ 'X-Test': 'value'
161
+ })
162
+ })
163
+ );
164
+ });
165
+
166
+ test('honors explicit idempotent: false on a GET request', async () => {
167
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ ok: true }));
168
+
169
+ await restService.get('/Configuration/ServerName', { idempotent: false });
170
+
171
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
172
+ expect.objectContaining({
173
+ _idempotent: false
174
+ })
175
+ );
176
+ });
177
+
178
+ test('preserves caller-supplied validateStatus when verifyResponse is false', async () => {
179
+ const callerValidate = jest.fn().mockReturnValue(true);
180
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({}, 500));
181
+
182
+ await restService.get('/bad', {
183
+ verifyResponse: false,
184
+ validateStatus: callerValidate
185
+ });
186
+
187
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
188
+ expect.objectContaining({
189
+ validateStatus: callerValidate
190
+ })
191
+ );
192
+ });
193
+
194
+ test('skips response verification when verifyResponse is false', async () => {
195
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ error: 'bad request' }, 400));
196
+
197
+ const response = await restService.get('/bad-request', { verifyResponse: false });
198
+
199
+ expect(response.status).toBe(400);
200
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
201
+ expect.objectContaining({
202
+ validateStatus: expect.any(Function)
203
+ })
204
+ );
205
+ });
206
+
207
+ test('throws when async response has no Location header', async () => {
208
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({}, 202));
209
+
210
+ await expect(restService.post('/Processes', {}, { asyncRequestsMode: true }))
211
+ .rejects
212
+ .toThrow(TM1RestException);
213
+ });
214
+
215
+ test('returns initial response when async request completes synchronously', async () => {
216
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ ok: true }, 200));
217
+
218
+ const response = await restService.get('/Threads', { asyncRequestsMode: true });
219
+
220
+ expect(response.status).toBe(200);
221
+ expect(mockAxiosInstance.get).not.toHaveBeenCalled();
222
+ });
223
+
224
+ test('propagates errors thrown by poll requests', async () => {
225
+ const pollError = new TM1RestException('Internal Server Error', 500);
226
+ mockAxiosInstance.request
227
+ .mockResolvedValueOnce(createMockResponse({}, 202, {
228
+ location: "/api/v1/_async('async-err')"
229
+ }))
230
+ .mockRejectedValueOnce(pollError);
231
+
232
+ await expect(restService.get('/Threads', { asyncRequestsMode: true }))
233
+ .rejects.toBe(pollError);
234
+ });
235
+
236
+ test('cancels async operation on timeout when cancelAtTimeout is true', async () => {
237
+ jest.useFakeTimers();
238
+
239
+ mockAxiosInstance.request.mockImplementation((config: any) => {
240
+ if (config.method === 'GET' && config.url === '/Threads') {
241
+ return Promise.resolve(createMockResponse({}, 202, {
242
+ location: "/api/v1/_async('async-timeout')"
243
+ }));
244
+ }
245
+ if (config.method === 'DELETE') {
246
+ return Promise.resolve(createMockResponse({}, 204));
247
+ }
248
+ return Promise.resolve(createMockResponse({}, 202));
249
+ });
250
+
251
+ const pending = restService.get('/Threads', {
252
+ asyncRequestsMode: true,
253
+ timeout: 0.25,
254
+ cancelAtTimeout: true
255
+ });
256
+ const expectation = expect(pending).rejects.toThrow(TM1TimeoutException);
257
+
258
+ await Promise.resolve();
259
+ await jest.advanceTimersByTimeAsync(1000);
260
+
261
+ await expectation;
262
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
263
+ expect.objectContaining({
264
+ method: 'DELETE',
265
+ url: "/_async('async-timeout')"
266
+ })
267
+ );
268
+ });
269
+
270
+ test('retry interceptor does not retry non-idempotent requests', async () => {
271
+ expect(responseErrorHandler).toBeDefined();
272
+
273
+ const error = {
274
+ config: { _idempotent: false },
275
+ code: 'ECONNRESET',
276
+ message: 'socket hang up'
277
+ };
278
+
279
+ await expect(responseErrorHandler!(error)).rejects.toThrow('socket hang up');
280
+ expect(mockAxiosInstance).not.toHaveBeenCalled();
281
+ });
282
+
283
+ test('retry interceptor retries idempotent requests', async () => {
284
+ jest.useFakeTimers();
285
+ expect(responseErrorHandler).toBeDefined();
286
+
287
+ mockAxiosInstance.mockResolvedValue(createMockResponse({ ok: true }));
288
+
289
+ const error = {
290
+ config: { _idempotent: true, headers: {} },
291
+ code: 'ECONNRESET',
292
+ message: 'socket hang up'
49
293
  };
50
-
51
- restService = new RestService(config);
294
+
295
+ const retryPromise = responseErrorHandler!(error);
296
+ await jest.advanceTimersByTimeAsync(2000);
297
+
298
+ await expect(retryPromise).resolves.toMatchObject({ data: { ok: true } });
299
+ expect(mockAxiosInstance).toHaveBeenCalledWith(
300
+ expect.objectContaining({
301
+ _retryCount: 1
302
+ })
303
+ );
304
+ });
305
+
306
+ test('waitTimeGenerator produces capped exponential backoff', () => {
307
+ const generator = (restService as any).waitTimeGenerator(4);
308
+ const waits = Array.from({ length: 7 }, () => generator.next().value);
309
+
310
+ expect(waits).toEqual([0.1, 0.2, 0.4, 0.8, 1, 1, 1]);
311
+ });
312
+
313
+ test('waitTimeGenerator runs unbounded when timeout is falsy', () => {
314
+ const generator = (restService as any).waitTimeGenerator(0);
315
+ const waits = Array.from({ length: 5 }, () => generator.next().value);
316
+
317
+ expect(waits).toEqual([0.1, 0.2, 0.4, 0.8, 1]);
318
+ expect(generator.next().done).toBe(false);
319
+ });
320
+
321
+ test('waitTimeGenerator stops once timeout is exceeded', () => {
322
+ const generator = (restService as any).waitTimeGenerator(0.5);
323
+ const waits: number[] = [];
324
+
325
+ while (true) {
326
+ const next = generator.next();
327
+ if (next.done) {
328
+ break;
329
+ }
330
+ waits.push(next.value);
331
+ }
332
+
333
+ expect(waits).toEqual([0.1, 0.2, 0.4]);
334
+ });
335
+
336
+ test('cancel_async_operation uses DELETE against /_async', async () => {
337
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({}, 204));
338
+
339
+ await restService.cancel_async_operation('cancel-001');
340
+
341
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
342
+ expect.objectContaining({
343
+ method: 'DELETE',
344
+ url: "/_async('cancel-001')"
345
+ })
346
+ );
347
+ });
348
+
349
+ test('retrieve_async_response uses /_async and returns full response', async () => {
350
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ Status: 'CompletedSuccessfully' }));
351
+
352
+ const response = await restService.retrieve_async_response('poll-001');
353
+
354
+ expect(response.data.Status).toBe('CompletedSuccessfully');
355
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
356
+ expect.objectContaining({
357
+ method: 'GET',
358
+ url: "/_async('poll-001')"
359
+ })
360
+ );
361
+ });
362
+
363
+ test('async dispatcher retries on transient 404 from /_async resource not yet materialized', async () => {
364
+ mockAxiosInstance.request
365
+ .mockResolvedValueOnce(createMockResponse({}, 202, {
366
+ location: "/api/v1/_async('async-404')"
367
+ }))
368
+ .mockResolvedValueOnce(createMockResponse({}, 404))
369
+ .mockResolvedValueOnce(createMockResponse({ done: true }, 200));
370
+
371
+ const response = await restService.get('/Threads', { asyncRequestsMode: true });
372
+
373
+ expect(response.data.done).toBe(true);
374
+ expect(mockAxiosInstance.request).toHaveBeenNthCalledWith(2,
375
+ expect.objectContaining({
376
+ method: 'GET',
377
+ url: "/_async('async-404')",
378
+ validateStatus: expect.any(Function)
379
+ })
380
+ );
381
+ });
382
+
383
+ test('async dispatcher throws when poll response carries non-2xx asyncresult header', async () => {
384
+ mockAxiosInstance.request
385
+ .mockResolvedValueOnce(createMockResponse({}, 202, {
386
+ location: "/api/v1/_async('async-fail')"
387
+ }))
388
+ .mockResolvedValueOnce(createMockResponse({}, 200, {
389
+ asyncresult: '500 Internal Server Error'
390
+ }));
391
+
392
+ await expect(restService.get('/Threads', { asyncRequestsMode: true }))
393
+ .rejects.toMatchObject({ status: 500 });
394
+ });
395
+
396
+ test('wait_for_async_operation throws when asyncresult header encodes non-2xx status', async () => {
397
+ mockAxiosInstance.request.mockResolvedValue(
398
+ createMockResponse({ ok: false }, 200, {
399
+ asyncresult: '500 Internal Server Error'
400
+ })
401
+ );
402
+
403
+ await expect(restService.wait_for_async_operation('poll-500', 1))
404
+ .rejects.toMatchObject({ status: 500 });
405
+ });
406
+
407
+ test('wait_for_async_operation returns response data', async () => {
408
+ mockAxiosInstance.request.mockResolvedValue(createMockResponse({ Status: 'Completed', Result: 1 }, 200));
409
+
410
+ const data = await restService.wait_for_async_operation('poll-002', 1);
411
+
412
+ expect(data).toEqual({ Status: 'Completed', Result: 1 });
413
+ expect(mockAxiosInstance.request).toHaveBeenCalledWith(
414
+ expect.objectContaining({
415
+ method: 'GET',
416
+ url: "/_async('poll-002')"
417
+ })
418
+ );
52
419
  });
53
420
 
54
- describe('Basic HTTP Operations', () => {
55
- test('should handle GET requests', async () => {
56
- const mockResponse = createMockResponse({ value: '11.8.0' });
57
- (restService as any).axiosInstance.get.mockResolvedValue(mockResponse);
421
+ describe('Cookie-based Session Management', () => {
422
+ const makeSvc = (overrides: any = {}) => {
423
+ const instance = {
424
+ get: jest.fn(),
425
+ post: jest.fn(),
426
+ patch: jest.fn(),
427
+ delete: jest.fn(),
428
+ put: jest.fn(),
429
+ defaults: { headers: { common: {} as Record<string, string> } },
430
+ interceptors: {
431
+ request: { use: jest.fn() },
432
+ response: { use: jest.fn() }
433
+ }
434
+ };
435
+ mockedAxios.create.mockReturnValue(instance as any);
436
+ const svc = new RestService({
437
+ baseUrl: 'http://localhost:8879/api/v1',
438
+ user: 'admin',
439
+ password: 'password',
440
+ ...overrides
441
+ });
442
+ return { svc, instance };
443
+ };
444
+
445
+ describe('parseSetCookieHeaders', () => {
446
+ test('captures TM1SessionId from Set-Cookie array with Domain/Path attributes', () => {
447
+ const { svc } = makeSvc();
448
+ (svc as any).parseSetCookieHeaders(['TM1SessionId=abc123; Path=/; HttpOnly']);
449
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBe('abc123');
450
+ });
451
+
452
+ test('captures paSession (v12) from Set-Cookie', () => {
453
+ const { svc } = makeSvc();
454
+ (svc as any).parseSetCookieHeaders(['paSession=v12xyz; Domain=backend.local; Path=/']);
455
+ expect((svc as any).sessionCookies.get('paSession')).toBe('v12xyz');
456
+ });
457
+
458
+ test('accepts single string input', () => {
459
+ const { svc } = makeSvc();
460
+ (svc as any).parseSetCookieHeaders('TM1SessionId=single; Path=/');
461
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBe('single');
462
+ });
463
+
464
+ test('ignores undefined / empty array / malformed input', () => {
465
+ const { svc } = makeSvc();
466
+ (svc as any).parseSetCookieHeaders(undefined);
467
+ (svc as any).parseSetCookieHeaders([]);
468
+ (svc as any).parseSetCookieHeaders(['malformed_no_equals']);
469
+ expect((svc as any).sessionCookies.size).toBe(0);
470
+ });
471
+
472
+ test('empty value deletes the stored cookie', () => {
473
+ const { svc } = makeSvc();
474
+ (svc as any).sessionCookies.set('TM1SessionId', 'x');
475
+ (svc as any).parseSetCookieHeaders(['TM1SessionId=; Max-Age=0']);
476
+ expect((svc as any).sessionCookies.has('TM1SessionId')).toBe(false);
477
+ });
478
+
479
+ test('ignores non-session cookies', () => {
480
+ const { svc } = makeSvc();
481
+ (svc as any).parseSetCookieHeaders([
482
+ 'BIGipServer=xxx; Path=/',
483
+ 'JSESSIONID=yyy'
484
+ ]);
485
+ expect((svc as any).sessionCookies.size).toBe(0);
486
+ });
58
487
 
59
- const response = await restService.get('/Configuration/ProductVersion');
60
-
61
- expect(response.status).toBe(200);
62
- expect(response.data.value).toBe('11.8.0');
488
+ test('cookie with bogus Domain still produces outbound Cookie on next call (reverse-proxy)', () => {
489
+ const { svc } = makeSvc();
490
+ (svc as any).parseSetCookieHeaders([
491
+ 'TM1SessionId=proxied; Domain=internal.backend; Path=/'
492
+ ]);
493
+ const header = (svc as any).buildCookieHeader();
494
+ expect(header).toBe('TM1SessionId=proxied');
495
+ expect(header).not.toContain('Domain');
496
+ expect(header).not.toContain('Path');
497
+ });
63
498
  });
64
499
 
65
- test('should handle POST requests', async () => {
66
- const mockResponse = createMockResponse({}, 201);
67
- (restService as any).axiosInstance.post.mockResolvedValue(mockResponse);
500
+ describe('buildCookieHeader', () => {
501
+ test('empty store returns undefined', () => {
502
+ const { svc } = makeSvc();
503
+ expect((svc as any).buildCookieHeader()).toBeUndefined();
504
+ });
68
505
 
69
- const response = await restService.post('/test', { data: 'test' });
70
-
71
- expect(response.status).toBe(201);
506
+ test('serializes multiple cookies as name=value; name=value', () => {
507
+ const { svc } = makeSvc();
508
+ (svc as any).sessionCookies.set('TM1SessionId', 'a');
509
+ (svc as any).sessionCookies.set('paSession', 'b');
510
+ const header = (svc as any).buildCookieHeader() as string;
511
+ const parts = header.split('; ').sort();
512
+ expect(parts).toEqual(['TM1SessionId=a', 'paSession=b'].sort());
513
+ });
72
514
  });
73
515
 
74
- test('should handle PATCH requests', async () => {
75
- const mockResponse = createMockResponse({}, 200);
76
- (restService as any).axiosInstance.patch.mockResolvedValue(mockResponse);
516
+ describe('getSessionCookieValue', () => {
517
+ test('TM1SessionId wins over paSession when both are stored', () => {
518
+ const { svc } = makeSvc();
519
+ (svc as any).sessionCookies.set('TM1SessionId', 'v11');
520
+ (svc as any).sessionCookies.set('paSession', 'v12');
521
+ expect((svc as any).getSessionCookieValue()).toBe('v11');
522
+ });
77
523
 
78
- const response = await restService.patch('/test', { data: 'updated' });
79
-
80
- expect(response.status).toBe(200);
524
+ test('returns paSession when only v12 cookie is stored', () => {
525
+ const { svc } = makeSvc();
526
+ (svc as any).sessionCookies.set('paSession', 'v12-only');
527
+ expect((svc as any).getSessionCookieValue()).toBe('v12-only');
528
+ });
81
529
  });
82
530
 
83
- test('should handle DELETE requests', async () => {
84
- const mockResponse = createMockResponse({}, 204);
85
- (restService as any).axiosInstance.delete.mockResolvedValue(mockResponse);
531
+ describe('Constructor seeding', () => {
532
+ test('seeds TM1SessionId when config.sessionId is provided', () => {
533
+ const { svc } = makeSvc({ sessionId: 'seeded-abc' });
534
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBe('seeded-abc');
535
+ expect(svc.getSessionId()).toBe('seeded-abc');
536
+ });
86
537
 
87
- const response = await restService.delete('/test');
88
-
89
- expect(response.status).toBe(204);
538
+ test('does not seed when config.sessionId is absent', () => {
539
+ const { svc } = makeSvc();
540
+ expect((svc as any).sessionCookies.size).toBe(0);
541
+ });
90
542
  });
91
543
 
92
- test('should handle PUT requests', async () => {
93
- const mockResponse = createMockResponse({}, 200);
94
- (restService as any).axiosInstance.put.mockResolvedValue(mockResponse);
544
+ describe('connect / disconnect', () => {
545
+ test('connect removes Authorization from axios defaults after success', async () => {
546
+ const { svc, instance } = makeSvc();
547
+ instance.defaults.headers.common['Authorization'] = 'Basic xxx';
548
+ instance.get.mockResolvedValue(createMockResponse({ value: 'Server1' }));
549
+ // Simulate server issuing a session cookie so stripping Authorization is safe
550
+ (svc as any).sessionCookies.set('TM1SessionId', 'from-server');
551
+
552
+ await svc.connect();
553
+
554
+ expect(instance.defaults.headers.common['Authorization']).toBeUndefined();
555
+ expect(svc.isLoggedIn()).toBe(true);
556
+ });
557
+
558
+ test('connect preserves Authorization when no session cookie was issued (Bearer-only mode)', async () => {
559
+ const { svc, instance } = makeSvc({ accessToken: 'bearer-xyz' });
560
+ instance.defaults.headers.common['Authorization'] = 'Bearer bearer-xyz';
561
+ instance.get.mockResolvedValue(createMockResponse({ value: 'Server1' }));
562
+
563
+ await svc.connect();
564
+
565
+ expect(instance.defaults.headers.common['Authorization']).toBe('Bearer bearer-xyz');
566
+ });
567
+
568
+ test('connect skips setupAuthentication when config.sessionId was provided', async () => {
569
+ const { svc, instance } = makeSvc({ sessionId: 'seed' });
570
+ const authSpy = jest.fn();
571
+ (svc as any).setupAuthentication = authSpy;
572
+ instance.get.mockResolvedValue(createMockResponse({ value: 'Server1' }));
573
+
574
+ await svc.connect();
575
+
576
+ expect(authSpy).not.toHaveBeenCalled();
577
+ expect(instance.get).toHaveBeenCalledWith(
578
+ '/Configuration/ServerName',
579
+ expect.objectContaining({ _idempotent: false })
580
+ );
581
+ expect(svc.isLoggedIn()).toBe(true);
582
+ });
583
+
584
+ test('connect probe is marked non-idempotent so interceptor retry skips it', async () => {
585
+ const { svc, instance } = makeSvc({ sessionId: 'seed' });
586
+ instance.get.mockResolvedValue(createMockResponse({ value: 'Server1' }));
587
+
588
+ await svc.connect();
589
+
590
+ const probeConfig = instance.get.mock.calls[0][1];
591
+ expect(probeConfig).toBeDefined();
592
+ expect(probeConfig._idempotent).toBe(false);
593
+ });
594
+
595
+ test('connect calls setupAuthentication when no session cookie is seeded', async () => {
596
+ const { svc, instance } = makeSvc();
597
+ const authSpy = jest.fn().mockResolvedValue(undefined);
598
+ (svc as any).setupAuthentication = authSpy;
599
+ instance.get.mockResolvedValue(createMockResponse({ value: 'Server1' }));
600
+
601
+ await svc.connect();
602
+
603
+ expect(authSpy).toHaveBeenCalledTimes(1);
604
+ });
605
+
606
+ test('disconnect clears sessionCookies and flips isLoggedIn to false', async () => {
607
+ const { svc, instance } = makeSvc();
608
+ (svc as any).sessionCookies.set('TM1SessionId', 'abc');
609
+ (svc as any).isConnected = true;
610
+ instance.post.mockResolvedValue(createMockResponse({}, 204));
95
611
 
96
- const response = await restService.put('/test', { data: 'test' });
97
-
98
- expect(response.status).toBe(200);
612
+ await svc.disconnect();
613
+
614
+ expect((svc as any).sessionCookies.size).toBe(0);
615
+ expect(svc.isLoggedIn()).toBe(false);
616
+ });
99
617
  });
100
- });
101
618
 
102
- describe('Configuration and Setup', () => {
103
- test('should build base URL correctly', () => {
104
- expect(restService).toBeDefined();
105
- expect((restService as any).config.baseUrl).toContain('localhost:8879');
619
+ describe('removeAuthorizationHeader', () => {
620
+ test('deletes Authorization from axios defaults', () => {
621
+ const { svc, instance } = makeSvc();
622
+ instance.defaults.headers.common['Authorization'] = 'Basic xxx';
623
+ (svc as any).removeAuthorizationHeader();
624
+ expect(instance.defaults.headers.common['Authorization']).toBeUndefined();
625
+ });
106
626
  });
107
627
 
108
- test('should set timeout correctly', () => {
109
- expect((restService as any).config.timeout).toBe(30000);
628
+ describe('Interceptor flow', () => {
629
+ // Exercises the response interceptor that RestService installs during construction
630
+ // via axios.defaults. Captured at test time from the real interceptor-install call.
631
+ let capturedResponseSuccess: (r: any) => any;
632
+ let capturedResponseError: (e: any) => Promise<any>;
633
+ let capturedRequest: (c: any) => any;
634
+ let realSvc: RestService;
635
+ let realInstance: any;
636
+
637
+ beforeEach(() => {
638
+ capturedRequest = (c: any) => c;
639
+ capturedResponseSuccess = (r: any) => r;
640
+ capturedResponseError = async (e: any) => Promise.reject(e);
641
+ realInstance = {
642
+ get: jest.fn(),
643
+ post: jest.fn(),
644
+ defaults: { headers: { common: {} as Record<string, string> } },
645
+ interceptors: {
646
+ request: { use: jest.fn((fn: any) => { capturedRequest = fn; }) },
647
+ response: { use: jest.fn((success: any, err: any) => {
648
+ capturedResponseSuccess = success;
649
+ capturedResponseError = err;
650
+ })}
651
+ }
652
+ };
653
+ mockedAxios.create.mockReturnValue(realInstance);
654
+ // Make axiosInstance callable as a function for retry replay
655
+ const callable: any = jest.fn();
656
+ Object.assign(callable, realInstance);
657
+ mockedAxios.create.mockReturnValue(callable);
658
+ realInstance = callable;
659
+ realSvc = new RestService({ baseUrl: 'http://x/api/v1', user: 'a', password: 'b' });
660
+ });
661
+
662
+ test('response interceptor captures Set-Cookie on success', () => {
663
+ capturedResponseSuccess({
664
+ headers: { 'set-cookie': ['TM1SessionId=captured; Path=/'] },
665
+ data: {}, status: 200
666
+ });
667
+ expect((realSvc as any).sessionCookies.get('TM1SessionId')).toBe('captured');
668
+ });
669
+
670
+ test('response interceptor captures Set-Cookie on error responses too', async () => {
671
+ // Use a 403 (not a retryable 5xx, not a 401 re-auth trigger) so it falls through
672
+ // to the throw path without being replayed by the retry logic
673
+ await expect(capturedResponseError({
674
+ response: { status: 403, statusText: 'Forbidden', data: {}, headers: { 'set-cookie': ['paSession=fromErr; Path=/'] } },
675
+ config: { headers: {} },
676
+ message: 'Forbidden'
677
+ })).rejects.toBeDefined();
678
+ expect((realSvc as any).sessionCookies.get('paSession')).toBe('fromErr');
679
+ });
680
+
681
+ test('request interceptor writes Cookie header from the store', () => {
682
+ (realSvc as any).sessionCookies.set('TM1SessionId', 'outbound');
683
+ const out = capturedRequest({ headers: {} });
684
+ expect(out.headers['Cookie']).toBe('TM1SessionId=outbound');
685
+ });
686
+
687
+ test('401 triggers reAuth and replays the request with fresh Cookie, no stale Authorization', async () => {
688
+ (realSvc as any).isConnected = true;
689
+ (realSvc as any).sessionCookies.set('TM1SessionId', 'expired');
690
+ // reAuthenticate() calls disconnect() + connect(); mock both network calls to succeed
691
+ realInstance.post.mockResolvedValue(createMockResponse({}, 204));
692
+ realInstance.get.mockResolvedValue(createMockResponse({ value: 'Server1' }));
693
+ // Simulate new server-issued cookie during connect's probe by seeding directly —
694
+ // the real interceptor would capture it from set-cookie, but we short-circuit here
695
+ const reAuthSpy = jest.spyOn(realSvc as any, 'reAuthenticate').mockImplementation(async () => {
696
+ (realSvc as any).sessionCookies.clear();
697
+ (realSvc as any).sessionCookies.set('TM1SessionId', 'fresh');
698
+ });
699
+ // axios instance is callable — replay returns a sentinel
700
+ const replayed = createMockResponse({ ok: true }, 200);
701
+ (realInstance as unknown as jest.Mock).mockResolvedValue(replayed);
702
+
703
+ const originalRequest: any = {
704
+ headers: { 'Cookie': 'TM1SessionId=expired', 'authorization': 'Basic lowercase' },
705
+ url: '/SomeEndpoint',
706
+ };
707
+ const result = await capturedResponseError({
708
+ response: { status: 401, headers: {} },
709
+ config: originalRequest,
710
+ message: 'Unauthorized',
711
+ });
712
+
713
+ expect(reAuthSpy).toHaveBeenCalledTimes(1);
714
+ expect(originalRequest._retry).toBe(true);
715
+ expect(originalRequest.headers['Cookie']).toBeUndefined();
716
+ // Case-insensitive delete caught the lowercase variant
717
+ expect(originalRequest.headers['authorization']).toBeUndefined();
718
+ expect(result).toBe(replayed);
719
+ });
720
+
721
+ test('401 on tm1.Close does not recurse into reAuthenticate (isConnected guard)', async () => {
722
+ (realSvc as any).isConnected = false;
723
+ const reAuthSpy = jest.spyOn(realSvc as any, 'reAuthenticate');
724
+ await expect(capturedResponseError({
725
+ response: { status: 401, statusText: 'Unauthorized', data: {}, headers: {} },
726
+ config: { headers: {} },
727
+ message: 'Unauthorized',
728
+ })).rejects.toBeDefined();
729
+ expect(reAuthSpy).not.toHaveBeenCalled();
730
+ });
110
731
  });
111
732
 
112
- test('should handle authentication config', () => {
113
- expect((restService as any).config.user).toBe('admin');
114
- expect((restService as any).config.password).toBe('password');
733
+ describe('isLoggedIn branches', () => {
734
+ test('returns false when not connected', () => {
735
+ const { svc } = makeSvc();
736
+ expect(svc.isLoggedIn()).toBe(false);
737
+ });
738
+
739
+ test('returns false when connected but no session cookie', () => {
740
+ const { svc } = makeSvc();
741
+ (svc as any).isConnected = true;
742
+ expect(svc.isLoggedIn()).toBe(false);
743
+ });
744
+
745
+ test('returns true when connected AND session cookie present', () => {
746
+ const { svc } = makeSvc();
747
+ (svc as any).isConnected = true;
748
+ (svc as any).sessionCookies.set('TM1SessionId', 'abc');
749
+ expect(svc.isLoggedIn()).toBe(true);
750
+ });
115
751
  });
116
752
  });
753
+ });
754
+
755
+ describe('RestService URL topology dispatch', () => {
756
+ let mockAxiosInstance: any;
757
+
758
+ beforeEach(() => {
759
+ jest.clearAllMocks();
760
+ mockAxiosInstance = {
761
+ get: jest.fn(),
762
+ post: jest.fn(),
763
+ patch: jest.fn(),
764
+ delete: jest.fn(),
765
+ put: jest.fn(),
766
+ interceptors: {
767
+ request: { use: jest.fn() },
768
+ response: { use: jest.fn() }
769
+ },
770
+ defaults: { headers: { common: {} } }
771
+ };
772
+ mockedAxios.create.mockReturnValue(mockAxiosInstance as any);
773
+ });
774
+
775
+ const firstCreateArg = (): any => mockedAxios.create.mock.calls[0][0];
776
+ const lastBaseURL = (): string => firstCreateArg().baseURL;
117
777
 
118
- describe('Session Management', () => {
119
- test('should handle session ID', () => {
120
- restService.setSandbox('TestSandbox');
121
- expect(restService.getSandbox()).toBe('TestSandbox');
778
+ describe('v11 pattern', () => {
779
+ test('should build v11 URL with ssl=true and default port', () => {
780
+ const svc = new RestService({ address: 'host', ssl: true });
781
+ expect(lastBaseURL()).toBe('https://host:8001/api/v1');
782
+ expect((svc as any).resolveRoots().authRoot).toBe('https://host:8001/api/v1/Configuration/ProductVersion/$value');
122
783
  });
123
784
 
124
- test('should check login status', () => {
125
- // Initially not logged in
126
- expect(restService.isLoggedIn()).toBe(false);
785
+ test('should build v11 URL with ssl=false and explicit port', () => {
786
+ new RestService({ address: 'host', port: 9000, ssl: false });
787
+ expect(lastBaseURL()).toBe('http://host:9000/api/v1');
788
+ });
789
+
790
+ test('should default address to localhost when omitted', () => {
791
+ new RestService({ ssl: false, port: 8001 });
792
+ expect(lastBaseURL()).toBe('http://localhost:8001/api/v1');
127
793
  });
128
794
  });
129
795
 
130
- describe('Error Handling', () => {
131
- test('should handle network errors', async () => {
132
- const networkError = new Error('Network Error');
133
- (restService as any).axiosInstance.get.mockRejectedValue(networkError);
796
+ describe('baseUrl override', () => {
797
+ test('should use baseUrl verbatim when it ends with /api/v1', () => {
798
+ const svc = new RestService({ baseUrl: 'http://x/api/v1' });
799
+ expect(lastBaseURL()).toBe('http://x/api/v1');
800
+ expect((svc as any).resolveRoots().authRoot).toBe('http://x/api/v1/Configuration/ProductVersion/$value');
801
+ });
134
802
 
135
- await expect(restService.get('/test')).rejects.toThrow('Network Error');
803
+ test('should append /api/v1 when baseUrl lacks it', () => {
804
+ new RestService({ baseUrl: 'http://x' });
805
+ expect(lastBaseURL()).toBe('http://x/api/v1');
136
806
  });
137
807
 
138
- test('should handle HTTP errors', async () => {
139
- const httpError = {
140
- response: {
141
- status: 404,
142
- statusText: 'Not Found',
143
- data: { error: 'Resource not found' }
144
- }
145
- };
146
- (restService as any).axiosInstance.get.mockRejectedValue(httpError);
808
+ test('should preserve TM1 11 IBM Cloud baseUrl shape verbatim', () => {
809
+ new RestService({
810
+ baseUrl: 'https://mycompany.planning-analytics.ibmcloud.com/tm1/api/tm1/'
811
+ });
812
+ expect(lastBaseURL()).toBe('https://mycompany.planning-analytics.ibmcloud.com/tm1/api/tm1');
813
+ });
147
814
 
148
- await expect(restService.get('/nonexistent')).rejects.toMatchObject(httpError);
815
+ test('should preserve TM1 12 PaaS baseUrl shape (trailing slash normalized)', () => {
816
+ new RestService({
817
+ baseUrl: 'https://us-east-1.planninganalytics.saas.ibm.com/api/T1/v0/tm1/DB1/'
818
+ });
819
+ expect(lastBaseURL()).toBe('https://us-east-1.planninganalytics.saas.ibm.com/api/T1/v0/tm1/DB1');
149
820
  });
150
821
 
151
- test('should handle timeout errors', async () => {
152
- const timeoutError = {
153
- code: 'ECONNABORTED',
154
- message: 'timeout of 30000ms exceeded'
155
- };
156
- (restService as any).axiosInstance.get.mockRejectedValue(timeoutError);
822
+ test('should preserve TM1 12 access-token baseUrl shape verbatim', () => {
823
+ new RestService({
824
+ baseUrl: 'https://pa12.dev.net/api/INST/v0/tm1/DB1'
825
+ });
826
+ expect(lastBaseURL()).toBe('https://pa12.dev.net/api/INST/v0/tm1/DB1');
827
+ });
157
828
 
158
- await expect(restService.get('/slow-endpoint')).rejects.toMatchObject(timeoutError);
829
+ test('should resolve Databases() baseUrl when authUrl provided', () => {
830
+ const svc = new RestService({
831
+ baseUrl: "http://x/api/v1/Databases('DB')",
832
+ authUrl: 'http://x/auth'
833
+ });
834
+ expect(lastBaseURL()).toBe("http://x/api/v1/Databases('DB')");
835
+ expect((svc as any).resolveRoots().authRoot).toBe('http://x/auth');
836
+ });
837
+
838
+ test('should throw for Databases() baseUrl without authUrl', () => {
839
+ expect(() => new RestService({
840
+ baseUrl: "http://x/api/v1/Databases('DB')"
841
+ })).toThrow(/Auth_url missing/);
159
842
  });
160
- });
161
843
 
162
- describe('API Metadata', () => {
163
- test('should get API metadata', async () => {
164
- const mockResponse = createMockResponse({
165
- version: '1.0',
166
- capabilities: ['read', 'write']
844
+ test('should let v12 signals win over baseUrl (tm1py parity)', () => {
845
+ const svc = new RestService({
846
+ baseUrl: 'http://ignored/api/v1',
847
+ address: 'pa.ibm.com',
848
+ tenant: 'T1',
849
+ database: 'DB1',
850
+ iamUrl: 'https://iam.cloud.ibm.com',
851
+ ssl: true
167
852
  });
168
- (restService as any).axiosInstance.get.mockResolvedValue(mockResponse);
853
+ expect(lastBaseURL()).toBe('https://pa.ibm.com/api/T1/v0/tm1/DB1');
854
+ expect((svc as any).resolveRoots().authRoot).toBe('https://pa.ibm.com/api/T1/v0/tm1/DB1/Configuration/ProductVersion/$value');
855
+ });
169
856
 
170
- const metadata = await restService.getApiMetadata();
171
-
172
- expect(metadata.version).toBe('1.0');
173
- expect(metadata.capabilities).toEqual(['read', 'write']);
857
+ test('should throw when baseUrl and address both provided', () => {
858
+ expect(() => new RestService({
859
+ baseUrl: 'http://x/api/v1',
860
+ address: 'y'
861
+ })).toThrow(/Base URL and Address/);
174
862
  });
175
863
  });
176
864
 
177
- describe('RestService Integration', () => {
178
- test('should handle complex request scenarios', async () => {
179
- // Mock a sequence of operations
180
- const getMockResponse = createMockResponse({ id: 'test123' });
181
- const postMockResponse = createMockResponse({}, 201);
182
- const patchMockResponse = createMockResponse({}, 200);
183
- const deleteMockResponse = createMockResponse({}, 204);
865
+ describe('IBM Cloud pattern', () => {
866
+ test('should build IBM Cloud URL when iamUrl provided', () => {
867
+ const svc = new RestService({
868
+ address: 'pa.ibm.com',
869
+ tenant: 'T1',
870
+ database: 'DB1',
871
+ iamUrl: 'https://iam.cloud.ibm.com',
872
+ ssl: true,
873
+ apiKey: 'k'
874
+ });
875
+ expect(lastBaseURL()).toBe('https://pa.ibm.com/api/T1/v0/tm1/DB1');
876
+ expect((svc as any).resolveRoots().authRoot).toBe('https://pa.ibm.com/api/T1/v0/tm1/DB1/Configuration/ProductVersion/$value');
877
+ });
878
+
879
+ test('should throw when IBM Cloud missing tenant', () => {
880
+ expect(() => new RestService({
881
+ address: 'pa.ibm.com',
882
+ database: 'DB1',
883
+ iamUrl: 'https://iam',
884
+ ssl: true
885
+ })).toThrow("'address', 'tenant' and 'database' must be provided to connect to TM1 > v12 in IBM Cloud");
886
+ });
184
887
 
185
- (restService as any).axiosInstance.get
186
- .mockResolvedValueOnce(getMockResponse);
187
- (restService as any).axiosInstance.post
188
- .mockResolvedValueOnce(postMockResponse);
189
- (restService as any).axiosInstance.patch
190
- .mockResolvedValueOnce(patchMockResponse);
191
- (restService as any).axiosInstance.delete
192
- .mockResolvedValueOnce(deleteMockResponse);
888
+ test('should throw when IBM Cloud ssl=false', () => {
889
+ expect(() => new RestService({
890
+ address: 'pa.ibm.com',
891
+ tenant: 'T1',
892
+ database: 'DB1',
893
+ iamUrl: 'https://iam',
894
+ ssl: false
895
+ })).toThrow(/ssl.*must be true/);
896
+ });
897
+ });
193
898
 
194
- // Execute sequence
195
- const getResponse = await restService.get('/test');
196
- expect(getResponse.data.id).toBe('test123');
899
+ describe('PA Proxy pattern', () => {
900
+ test('should build PA Proxy URL with https', () => {
901
+ const svc = new RestService({
902
+ address: 'h',
903
+ database: 'DB',
904
+ user: 'u',
905
+ paUrl: 'https://pa',
906
+ ssl: true
907
+ });
908
+ expect(lastBaseURL()).toBe('https://h/tm1/DB/api/v1');
909
+ expect((svc as any).resolveRoots().authRoot).toBe('https://h/login');
910
+ });
197
911
 
198
- const postResponse = await restService.post('/test', { name: 'test' });
199
- expect(postResponse.status).toBe(201);
912
+ test('should build PA Proxy URL with http', () => {
913
+ new RestService({
914
+ address: 'h',
915
+ database: 'DB',
916
+ user: 'u',
917
+ paUrl: 'http://pa',
918
+ ssl: false
919
+ });
920
+ expect(lastBaseURL()).toBe('http://h/tm1/DB/api/v1');
921
+ });
200
922
 
201
- const patchResponse = await restService.patch('/test', { name: 'updated' });
202
- expect(patchResponse.status).toBe(200);
923
+ test('should throw when PA Proxy missing database', () => {
924
+ expect(() => new RestService({
925
+ address: 'h',
926
+ user: 'u',
927
+ paUrl: 'https://pa',
928
+ ssl: true
929
+ })).toThrow(/'address'.*'database'.*must be provided/);
930
+ });
931
+ });
203
932
 
204
- const deleteResponse = await restService.delete('/test');
205
- expect(deleteResponse.status).toBe(204);
933
+ describe('S2S pattern', () => {
934
+ test('should build S2S URL with port and ssl', () => {
935
+ const svc = new RestService({
936
+ address: 'h',
937
+ port: 443,
938
+ instance: 'INST',
939
+ database: 'DB',
940
+ ssl: true
941
+ });
942
+ expect(lastBaseURL()).toBe("https://h:443/INST/api/v1/Databases('DB')");
943
+ expect((svc as any).resolveRoots().authRoot).toBe('https://h:443/INST/auth/v1/session');
206
944
  });
207
945
 
208
- test('should maintain consistency across operations', async () => {
209
- const mockResponse = createMockResponse({ consistent: true });
210
- (restService as any).axiosInstance.get.mockResolvedValue(mockResponse);
946
+ test('should build S2S URL without port', () => {
947
+ new RestService({
948
+ address: 'h',
949
+ instance: 'INST',
950
+ database: 'DB',
951
+ ssl: true
952
+ });
953
+ expect(lastBaseURL()).toBe("https://h/INST/api/v1/Databases('DB')");
954
+ });
211
955
 
212
- const response1 = await restService.get('/test');
213
- const response2 = await restService.get('/test');
956
+ test('should default to localhost when address is empty', () => {
957
+ new RestService({
958
+ address: '',
959
+ instance: 'I',
960
+ database: 'D',
961
+ ssl: false
962
+ });
963
+ expect(lastBaseURL()).toBe("http://localhost/I/api/v1/Databases('D')");
964
+ });
214
965
 
215
- expect(response1.data).toEqual(response2.data);
966
+ test('should throw S2S without instance', () => {
967
+ expect(() => new RestService({
968
+ address: 'h',
969
+ instance: 'INST',
970
+ ssl: true
971
+ })).toThrow(/instance.*database|instance.*required|database.*required/i);
216
972
  });
217
973
  });
218
- });
974
+
975
+ describe('Config pass-through and axios wiring', () => {
976
+ test('should accept new non-topology config fields without error', () => {
977
+ // iamUrl/paUrl/tenant/instance/database are topology signals (tested per-topology above);
978
+ // this asserts the remaining auth/network fields are accepted as config surface.
979
+ expect(() => new RestService({
980
+ baseUrl: 'http://x/api/v1',
981
+ cpdUrl: 'https://cpd',
982
+ gateway: 'https://gw',
983
+ integratedLogin: true,
984
+ integratedLoginDomain: '.',
985
+ integratedLoginService: 'HTTP',
986
+ integratedLoginHost: 'host',
987
+ integratedLoginDelegate: false,
988
+ user: 'admin',
989
+ password: 'pw'
990
+ })).not.toThrow();
991
+ });
992
+
993
+ test('should pass proxy.https to axios when provided', () => {
994
+ new RestService({
995
+ baseUrl: 'http://x/api/v1',
996
+ proxies: { https: 'https://proxy.example.com:8443' }
997
+ });
998
+ const cfg = firstCreateArg();
999
+ expect(cfg.proxy).toEqual({ host: 'proxy.example.com', port: 8443, protocol: 'https' });
1000
+ });
1001
+
1002
+ test('should fall back to proxy.http when https not provided', () => {
1003
+ new RestService({
1004
+ baseUrl: 'http://x/api/v1',
1005
+ proxies: { http: 'http://proxy.example.com:8080' }
1006
+ });
1007
+ const cfg = firstCreateArg();
1008
+ expect(cfg.proxy).toEqual({ host: 'proxy.example.com', port: 8080, protocol: 'http' });
1009
+ });
1010
+
1011
+ test('should not set proxy when proxies unset', () => {
1012
+ new RestService({ baseUrl: 'http://x/api/v1' });
1013
+ const cfg = firstCreateArg();
1014
+ expect(cfg.proxy).toBeUndefined();
1015
+ });
1016
+
1017
+ test('should forward credentials from proxy URL to proxy.auth', () => {
1018
+ new RestService({
1019
+ baseUrl: 'http://x/api/v1',
1020
+ proxies: { https: 'https://u%40dom:p%40ss@proxy.example.com:8443' }
1021
+ });
1022
+ const cfg = firstCreateArg();
1023
+ expect(cfg.proxy).toEqual({
1024
+ host: 'proxy.example.com',
1025
+ port: 8443,
1026
+ protocol: 'https',
1027
+ auth: { username: 'u@dom', password: 'p@ss' }
1028
+ });
1029
+ });
1030
+
1031
+ test('should not set proxy.auth when proxy URL has no credentials', () => {
1032
+ new RestService({
1033
+ baseUrl: 'http://x/api/v1',
1034
+ proxies: { https: 'https://proxy.example.com:8443' }
1035
+ });
1036
+ const cfg = firstCreateArg();
1037
+ expect(cfg.proxy.auth).toBeUndefined();
1038
+ });
1039
+
1040
+ test('should pass sslContext through as httpsAgent', () => {
1041
+ const httpsMod = require('https');
1042
+ const agent = new httpsMod.Agent();
1043
+ new RestService({
1044
+ baseUrl: 'http://x/api/v1',
1045
+ sslContext: agent
1046
+ });
1047
+ const cfg = firstCreateArg();
1048
+ expect(cfg.httpsAgent).toBe(agent);
1049
+ });
1050
+
1051
+ test('should not treat cpdUrl alone as v12 topology signal', () => {
1052
+ new RestService({
1053
+ address: 'host',
1054
+ port: 9000,
1055
+ ssl: false,
1056
+ cpdUrl: 'https://cpd'
1057
+ });
1058
+ expect(lastBaseURL()).toBe('http://host:9000/api/v1');
1059
+ });
1060
+
1061
+ test('should not treat gateway alone as v12 topology signal', () => {
1062
+ new RestService({
1063
+ address: 'host',
1064
+ port: 9000,
1065
+ ssl: false,
1066
+ gateway: 'https://gw'
1067
+ });
1068
+ expect(lastBaseURL()).toBe('http://host:9000/api/v1');
1069
+ });
1070
+ });
1071
+
1072
+ describe('Session cookie seeding by topology', () => {
1073
+ test('should seed TM1SessionId cookie for v11 topology', () => {
1074
+ const svc = new RestService({ address: 'host', ssl: true, sessionId: 'abc' });
1075
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBe('abc');
1076
+ expect((svc as any).sessionCookies.get('paSession')).toBeUndefined();
1077
+ });
1078
+
1079
+ test('should seed paSession cookie for IBM Cloud topology', () => {
1080
+ const svc = new RestService({
1081
+ address: 'pa.ibm.com',
1082
+ tenant: 'T1',
1083
+ database: 'DB1',
1084
+ iamUrl: 'https://iam',
1085
+ ssl: true,
1086
+ sessionId: 'abc'
1087
+ });
1088
+ expect((svc as any).sessionCookies.get('paSession')).toBe('abc');
1089
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBeUndefined();
1090
+ });
1091
+
1092
+ test('should seed paSession cookie for S2S topology', () => {
1093
+ const svc = new RestService({
1094
+ address: 'h',
1095
+ instance: 'INST',
1096
+ database: 'DB',
1097
+ ssl: true,
1098
+ sessionId: 'xyz'
1099
+ });
1100
+ expect((svc as any).sessionCookies.get('paSession')).toBe('xyz');
1101
+ });
1102
+
1103
+ test('should seed paSession cookie for PA Proxy topology', () => {
1104
+ const svc = new RestService({
1105
+ address: 'h',
1106
+ database: 'DB',
1107
+ user: 'u',
1108
+ paUrl: 'https://pa',
1109
+ ssl: true,
1110
+ sessionId: 'pp'
1111
+ });
1112
+ expect((svc as any).sessionCookies.get('paSession')).toBe('pp');
1113
+ });
1114
+
1115
+ test('should seed TM1SessionId cookie for baseUrl override', () => {
1116
+ const svc = new RestService({ baseUrl: 'http://x/api/v1', sessionId: 'ff' });
1117
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBe('ff');
1118
+ });
1119
+ });
1120
+
1121
+ describe('S2S token endpoint guard', () => {
1122
+ test('should throw when S2S auth runs on v11 topology without authUrl', async () => {
1123
+ const svc = new RestService({
1124
+ address: 'host',
1125
+ ssl: true,
1126
+ applicationClientId: 'id',
1127
+ applicationClientSecret: 'secret'
1128
+ });
1129
+ await expect((svc as any)._authenticateServiceToService()).rejects.toThrow(
1130
+ /'authUrl' is required for Service-to-Service authentication on v11 topology/
1131
+ );
1132
+ });
1133
+
1134
+ test('should throw when S2S auth runs on v11-style baseUrl topology without authUrl', async () => {
1135
+ const svc = new RestService({
1136
+ baseUrl: 'http://x/api/v1',
1137
+ applicationClientId: 'id',
1138
+ applicationClientSecret: 'secret'
1139
+ });
1140
+ await expect((svc as any)._authenticateServiceToService()).rejects.toThrow(
1141
+ /'authUrl' is required for Service-to-Service authentication on v11 topology/
1142
+ );
1143
+ });
1144
+
1145
+ test('should not throw when S2S auth runs on v12 Databases baseUrl with authUrl', async () => {
1146
+ const svc = new RestService({
1147
+ baseUrl: "http://x/api/v1/Databases('DB')",
1148
+ authUrl: 'http://x/auth',
1149
+ applicationClientId: 'id',
1150
+ applicationClientSecret: 'secret'
1151
+ });
1152
+ // Will reject with network-level error when trying to POST, but NOT the guard error.
1153
+ await expect((svc as any)._authenticateServiceToService())
1154
+ .rejects.not.toThrow(/'authUrl' is required/);
1155
+ });
1156
+ });
1157
+ });
1158
+
1159
+ // =========================================================================
1160
+ // Authentication flow tests — issue #59
1161
+ // =========================================================================
1162
+ describe('RestService authentication flows', () => {
1163
+ let mockAxiosInstance: any;
1164
+
1165
+ beforeEach(() => {
1166
+ jest.clearAllMocks();
1167
+ mockAxiosInstance = {
1168
+ get: jest.fn(),
1169
+ post: jest.fn(),
1170
+ patch: jest.fn(),
1171
+ delete: jest.fn(),
1172
+ put: jest.fn(),
1173
+ interceptors: {
1174
+ request: { use: jest.fn() },
1175
+ response: { use: jest.fn() }
1176
+ },
1177
+ defaults: { headers: { common: {} as Record<string, string> } }
1178
+ };
1179
+ mockedAxios.create.mockReturnValue(mockAxiosInstance as any);
1180
+ });
1181
+
1182
+ describe('getAuthenticationMode', () => {
1183
+ test('should detect BASIC when only user and password are provided', () => {
1184
+ const svc = new RestService({ address: 'host', ssl: true, user: 'admin', password: 'pw' });
1185
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.BASIC);
1186
+ });
1187
+
1188
+ test('should detect CAM when namespace is set without gateway', () => {
1189
+ const svc = new RestService({
1190
+ address: 'host', ssl: true,
1191
+ user: 'u', password: 'p', namespace: 'LDAP'
1192
+ });
1193
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.CAM);
1194
+ });
1195
+
1196
+ test('should detect CAM when camPassport is set', () => {
1197
+ const svc = new RestService({
1198
+ address: 'host', ssl: true, camPassport: 'passport123'
1199
+ });
1200
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.CAM);
1201
+ });
1202
+
1203
+ test('should detect CAM_SSO when gateway is set', () => {
1204
+ const svc = new RestService({
1205
+ address: 'host', ssl: true,
1206
+ user: 'u', password: 'p', namespace: 'LDAP', gateway: 'https://gw'
1207
+ });
1208
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.CAM_SSO);
1209
+ });
1210
+
1211
+ test('should detect IBM_CLOUD_API_KEY when iamUrl is set', () => {
1212
+ const svc = new RestService({
1213
+ address: 'pa.ibm.com', tenant: 'T1', database: 'DB1',
1214
+ iamUrl: 'https://iam.cloud.ibm.com', ssl: true, apiKey: 'k'
1215
+ });
1216
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.IBM_CLOUD_API_KEY);
1217
+ });
1218
+
1219
+ test('should detect PA_PROXY when address + user + paUrl (no instance)', () => {
1220
+ const svc = new RestService({
1221
+ address: 'host', user: 'u', password: 'p',
1222
+ paUrl: 'https://pa', database: 'db', ssl: true
1223
+ });
1224
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.PA_PROXY);
1225
+ });
1226
+
1227
+ test('should detect SERVICE_TO_SERVICE with instance + database', () => {
1228
+ const svc = new RestService({
1229
+ address: 'h', instance: 'INST', database: 'DB', ssl: true,
1230
+ applicationClientId: 'id', applicationClientSecret: 'secret'
1231
+ });
1232
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.SERVICE_TO_SERVICE);
1233
+ });
1234
+
1235
+ test('should detect SERVICE_TO_SERVICE on v11 when clientId + clientSecret provided', () => {
1236
+ const svc = new RestService({
1237
+ address: 'host', ssl: true,
1238
+ applicationClientId: 'id', applicationClientSecret: 'secret'
1239
+ });
1240
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.SERVICE_TO_SERVICE);
1241
+ });
1242
+
1243
+ test('should detect ACCESS_TOKEN when accessToken is set', () => {
1244
+ const svc = new RestService({
1245
+ baseUrl: 'http://x/api/v1', accessToken: 'jwt123'
1246
+ });
1247
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.ACCESS_TOKEN);
1248
+ });
1249
+
1250
+ test('should detect BASIC_API_KEY when apiKey is set', () => {
1251
+ const svc = new RestService({
1252
+ baseUrl: 'http://x/api/v1', apiKey: 'mykey'
1253
+ });
1254
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.BASIC_API_KEY);
1255
+ });
1256
+
1257
+ test('should fall through to BASIC when gateway is set without namespace', () => {
1258
+ const svc = new RestService({
1259
+ address: 'host', ssl: true,
1260
+ user: 'u', password: 'p', gateway: 'https://gw'
1261
+ });
1262
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.BASIC);
1263
+ });
1264
+
1265
+ test('should detect WIA when integratedLogin is set', () => {
1266
+ const svc = new RestService({
1267
+ address: 'host', ssl: true,
1268
+ integratedLogin: true
1269
+ });
1270
+ expect(svc.getAuthenticationMode()).toBe(AuthenticationMode.WIA);
1271
+ });
1272
+ });
1273
+
1274
+ describe('setupAuthentication — Basic', () => {
1275
+ test('should set Basic Authorization header', async () => {
1276
+ const svc = new RestService({
1277
+ baseUrl: 'http://x/api/v1', user: 'admin', password: 'apple'
1278
+ });
1279
+ await (svc as any).setupAuthentication();
1280
+ expect(mockAxiosInstance.defaults.headers.common['Authorization'])
1281
+ .toBe('Basic ' + Buffer.from('admin:apple').toString('base64'));
1282
+ });
1283
+
1284
+ test('should decode Base64 password when decodeB64 is true', async () => {
1285
+ const encoded = Buffer.from('mypassword').toString('base64');
1286
+ const svc = new RestService({
1287
+ baseUrl: 'http://x/api/v1', user: 'admin', password: encoded, decodeB64: true
1288
+ });
1289
+ await (svc as any).setupAuthentication();
1290
+ expect(mockAxiosInstance.defaults.headers.common['Authorization'])
1291
+ .toBe('Basic ' + Buffer.from('admin:mypassword').toString('base64'));
1292
+ });
1293
+
1294
+ test('should throw when no user or password for BASIC mode', async () => {
1295
+ const svc = new RestService({ baseUrl: 'http://x/api/v1' });
1296
+ await expect((svc as any).setupAuthentication())
1297
+ .rejects.toThrow('No valid authentication configuration provided');
1298
+ });
1299
+ });
1300
+
1301
+ describe('setupAuthentication — CAM (camPassport)', () => {
1302
+ test('should set CAMPassport Authorization header', async () => {
1303
+ const svc = new RestService({
1304
+ baseUrl: 'http://x/api/v1', camPassport: 'test-passport-value'
1305
+ });
1306
+ await (svc as any).setupAuthentication();
1307
+ expect(mockAxiosInstance.defaults.headers.common['Authorization'])
1308
+ .toBe('CAMPassport test-passport-value');
1309
+ });
1310
+ });
1311
+
1312
+ describe('setupAuthentication — CAM (namespace)', () => {
1313
+ test('should set CAMNamespace Authorization header', async () => {
1314
+ const svc = new RestService({
1315
+ baseUrl: 'http://x/api/v1',
1316
+ user: 'admin', password: 'pass', namespace: 'LDAP'
1317
+ });
1318
+ await (svc as any).setupAuthentication();
1319
+ const expected = 'CAMNamespace ' + Buffer.from('admin:pass:LDAP').toString('base64');
1320
+ expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBe(expected);
1321
+ });
1322
+
1323
+ test('should decode B64 password in CAMNamespace header', async () => {
1324
+ const encoded = Buffer.from('pass').toString('base64');
1325
+ const svc = new RestService({
1326
+ baseUrl: 'http://x/api/v1',
1327
+ user: 'admin', password: encoded, namespace: 'LDAP', decodeB64: true
1328
+ });
1329
+ await (svc as any).setupAuthentication();
1330
+ const expected = 'CAMNamespace ' + Buffer.from('admin:pass:LDAP').toString('base64');
1331
+ expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBe(expected);
1332
+ });
1333
+
1334
+ test('should throw CAM error when namespace set but no user/password/camPassport', async () => {
1335
+ const svc = new RestService({
1336
+ baseUrl: 'http://x/api/v1', namespace: 'LDAP'
1337
+ });
1338
+ await expect((svc as any).setupAuthentication())
1339
+ .rejects.toThrow('CAM authentication requires either camPassport or user/password/namespace');
1340
+ });
1341
+ });
1342
+
1343
+ describe('setupAuthentication — CAM_SSO (gateway)', () => {
1344
+ test('should GET gateway and set CAMPassport header from cam_passport cookie', async () => {
1345
+ (axios.get as jest.Mock).mockResolvedValue({
1346
+ status: 200,
1347
+ headers: {
1348
+ 'set-cookie': ['cam_passport=GW_PASSPORT_VALUE; Path=/; HttpOnly']
1349
+ }
1350
+ });
1351
+ const svc = new RestService({
1352
+ address: 'host', ssl: true,
1353
+ user: 'u', password: 'p', namespace: 'NS', gateway: 'https://gw.example.com'
1354
+ });
1355
+ await (svc as any).setupAuthentication();
1356
+ expect(axios.get).toHaveBeenCalledWith('https://gw.example.com', expect.objectContaining({
1357
+ params: { CAMNamespace: 'NS' }
1358
+ }));
1359
+ expect(mockAxiosInstance.defaults.headers.common['Authorization'])
1360
+ .toBe('CAMPassport GW_PASSPORT_VALUE');
1361
+ });
1362
+
1363
+ test('should throw when gateway response has no cam_passport cookie', async () => {
1364
+ (axios.get as jest.Mock).mockResolvedValue({
1365
+ status: 200,
1366
+ headers: { 'set-cookie': ['other=value; Path=/'] }
1367
+ });
1368
+ const svc = new RestService({
1369
+ address: 'host', ssl: true,
1370
+ user: 'u', password: 'p', namespace: 'NS', gateway: 'https://gw'
1371
+ });
1372
+ await expect((svc as any).setupAuthentication())
1373
+ .rejects.toThrow(/cam_passport/);
1374
+ });
1375
+
1376
+ test('should throw when gateway response has no Set-Cookie header', async () => {
1377
+ (axios.get as jest.Mock).mockResolvedValue({
1378
+ status: 200,
1379
+ headers: {}
1380
+ });
1381
+ const svc = new RestService({
1382
+ address: 'host', ssl: true,
1383
+ user: 'u', password: 'p', namespace: 'NS', gateway: 'https://gw'
1384
+ });
1385
+ await expect((svc as any).setupAuthentication())
1386
+ .rejects.toThrow(/cam_passport/);
1387
+ });
1388
+
1389
+ test('should throw when gateway returns non-200 status', async () => {
1390
+ (axios.get as jest.Mock).mockResolvedValue({
1391
+ status: 403,
1392
+ headers: {}
1393
+ });
1394
+ const svc = new RestService({
1395
+ address: 'host', ssl: true,
1396
+ user: 'u', password: 'p', namespace: 'NS', gateway: 'https://gw'
1397
+ });
1398
+ await expect((svc as any).setupAuthentication())
1399
+ .rejects.toThrow(/Expected status_code 200/);
1400
+ });
1401
+ });
1402
+
1403
+ describe('setupAuthentication — IBM_CLOUD_API_KEY (IAM token exchange)', () => {
1404
+ test('should exchange API key for IAM bearer token', async () => {
1405
+ (axios.post as jest.Mock).mockResolvedValue({
1406
+ data: { access_token: 'iam-bearer-token-123' }
1407
+ });
1408
+ const svc = new RestService({
1409
+ address: 'pa.ibm.com', tenant: 'T1', database: 'DB1',
1410
+ iamUrl: 'https://iam.cloud.ibm.com/identity/token',
1411
+ ssl: true, apiKey: 'test-api-key'
1412
+ });
1413
+ await (svc as any).setupAuthentication();
1414
+ expect(axios.post).toHaveBeenCalledWith(
1415
+ 'https://iam.cloud.ibm.com/identity/token',
1416
+ expect.stringContaining('grant_type=urn'),
1417
+ expect.objectContaining({
1418
+ headers: expect.objectContaining({
1419
+ 'Content-Type': 'application/x-www-form-urlencoded'
1420
+ })
1421
+ })
1422
+ );
1423
+ expect(mockAxiosInstance.defaults.headers.common['Authorization'])
1424
+ .toBe('Bearer iam-bearer-token-123');
1425
+ });
1426
+
1427
+ test('should include apiKey in URL-encoded payload', async () => {
1428
+ (axios.post as jest.Mock).mockResolvedValue({
1429
+ data: { access_token: 'token' }
1430
+ });
1431
+ const svc = new RestService({
1432
+ address: 'pa.ibm.com', tenant: 'T1', database: 'DB1',
1433
+ iamUrl: 'https://iam.cloud.ibm.com', ssl: true, apiKey: 'my-key'
1434
+ });
1435
+ await (svc as any).setupAuthentication();
1436
+ const calledPayload = (axios.post as jest.Mock).mock.calls[0][1];
1437
+ expect(calledPayload).toContain('apikey=my-key');
1438
+ expect(calledPayload).toContain('grant_type=');
1439
+ });
1440
+
1441
+ test('should throw when IAM response lacks access_token', async () => {
1442
+ (axios.post as jest.Mock).mockResolvedValue({ data: {} });
1443
+ const svc = new RestService({
1444
+ address: 'pa.ibm.com', tenant: 'T1', database: 'DB1',
1445
+ iamUrl: 'https://iam.cloud.ibm.com', ssl: true, apiKey: 'k'
1446
+ });
1447
+ await expect((svc as any).setupAuthentication())
1448
+ .rejects.toThrow(/Failed to generate access_token/);
1449
+ });
1450
+
1451
+ test('should throw when iamUrl is set but apiKey is missing', async () => {
1452
+ const svc = new RestService({
1453
+ address: 'pa.ibm.com', tenant: 'T1', database: 'DB1',
1454
+ iamUrl: 'https://iam.cloud.ibm.com', ssl: true
1455
+ });
1456
+ await expect((svc as any)._generateIbmIamCloudAccessToken())
1457
+ .rejects.toThrow(/'iamUrl' and 'apiKey' must be provided/);
1458
+ });
1459
+ });
1460
+
1461
+ describe('setupAuthentication — PA_PROXY (CPD + proxy auth)', () => {
1462
+ test('should generate CPD token then authenticate with PA Proxy', async () => {
1463
+ (axios.post as jest.Mock)
1464
+ // First call: CPD signin
1465
+ .mockResolvedValueOnce({
1466
+ data: { token: 'cpd-jwt-token-abc' }
1467
+ })
1468
+ // Second call: PA Proxy auth
1469
+ .mockResolvedValueOnce({
1470
+ status: 200,
1471
+ headers: {
1472
+ 'set-cookie': [
1473
+ 'ba-sso-csrf=csrf-value; Path=/',
1474
+ 'paSession=session123; Path=/'
1475
+ ]
1476
+ }
1477
+ });
1478
+ const svc = new RestService({
1479
+ address: 'host', user: 'user', password: 'pass',
1480
+ paUrl: 'https://pa', database: 'db', ssl: true,
1481
+ cpdUrl: 'https://cpd.example.com'
1482
+ });
1483
+ await (svc as any).setupAuthentication();
1484
+
1485
+ // Verify CPD signin was called
1486
+ expect(axios.post).toHaveBeenNthCalledWith(1,
1487
+ 'https://cpd.example.com/v1/preauth/signin',
1488
+ { username: 'user', password: 'pass' },
1489
+ expect.objectContaining({
1490
+ headers: expect.objectContaining({ 'Content-Type': 'application/json;charset=UTF-8' })
1491
+ })
1492
+ );
1493
+ // Verify PA Proxy auth was called with jwt
1494
+ expect(axios.post).toHaveBeenNthCalledWith(2,
1495
+ expect.stringContaining('/login'),
1496
+ 'jwt=cpd-jwt-token-abc',
1497
+ expect.objectContaining({
1498
+ headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' })
1499
+ })
1500
+ );
1501
+ // Verify ba-sso-authenticity header was set
1502
+ expect(mockAxiosInstance.defaults.headers.common['ba-sso-authenticity']).toBe('csrf-value');
1503
+ });
1504
+
1505
+ test('should throw when cpdUrl is missing for PA_PROXY', async () => {
1506
+ const svc = new RestService({
1507
+ address: 'host', user: 'u', password: 'p',
1508
+ paUrl: 'https://pa', database: 'db', ssl: true
1509
+ });
1510
+ await expect((svc as any).setupAuthentication())
1511
+ .rejects.toThrow(/'cpdUrl' must be provided to authenticate via CPD/);
1512
+ });
1513
+
1514
+ test('should throw when CPD response lacks token', async () => {
1515
+ (axios.post as jest.Mock).mockResolvedValue({ data: {} });
1516
+ const svc = new RestService({
1517
+ address: 'host', user: 'u', password: 'p',
1518
+ paUrl: 'https://pa', database: 'db', ssl: true,
1519
+ cpdUrl: 'https://cpd'
1520
+ });
1521
+ await expect((svc as any).setupAuthentication())
1522
+ .rejects.toThrow(/Failed to generate CPD access token/);
1523
+ });
1524
+ });
1525
+
1526
+ describe('setupAuthentication — SERVICE_TO_SERVICE', () => {
1527
+ test('should use Basic auth with clientId:clientSecret and POST {User: user}', async () => {
1528
+ (axios.post as jest.Mock).mockResolvedValue({
1529
+ status: 200,
1530
+ headers: {
1531
+ 'set-cookie': ['TM1SessionId=s2s-session-id; Path=/']
1532
+ }
1533
+ });
1534
+ const svc = new RestService({
1535
+ address: 'h', instance: 'INST', database: 'DB', ssl: true,
1536
+ applicationClientId: 'clientA', applicationClientSecret: 'secretB',
1537
+ user: 'admin'
1538
+ });
1539
+ await (svc as any).setupAuthentication();
1540
+
1541
+ const expectedBasicAuth = 'Basic ' + Buffer.from('clientA:secretB').toString('base64');
1542
+ expect(axios.post).toHaveBeenCalledWith(
1543
+ expect.stringContaining('/auth/v1/session'),
1544
+ JSON.stringify({ User: 'admin' }),
1545
+ expect.objectContaining({
1546
+ headers: expect.objectContaining({
1547
+ 'Authorization': expectedBasicAuth
1548
+ })
1549
+ })
1550
+ );
1551
+ // Session cookie should be captured
1552
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBe('s2s-session-id');
1553
+ });
1554
+
1555
+ test('should capture TM1SessionId from response with wrong domain attribute', async () => {
1556
+ (axios.post as jest.Mock).mockResolvedValue({
1557
+ status: 200,
1558
+ headers: {
1559
+ 'set-cookie': ['TM1SessionId=domain-id; Domain=wrong.domain; Path=/']
1560
+ }
1561
+ });
1562
+ const svc = new RestService({
1563
+ address: 'h', instance: 'INST', database: 'DB', ssl: true,
1564
+ applicationClientId: 'id', applicationClientSecret: 'secret',
1565
+ user: 'admin'
1566
+ });
1567
+ await (svc as any).setupAuthentication();
1568
+ // parseSetCookieHeaders strips Domain and captures the cookie directly
1569
+ expect((svc as any).sessionCookies.get('TM1SessionId')).toBe('domain-id');
1570
+ });
1571
+ });
1572
+
1573
+ describe('setupAuthentication — ACCESS_TOKEN', () => {
1574
+ test('should set Bearer token header', async () => {
1575
+ const svc = new RestService({
1576
+ baseUrl: 'http://x/api/v1', accessToken: 'my-jwt-token'
1577
+ });
1578
+ await (svc as any).setupAuthentication();
1579
+ expect(mockAxiosInstance.defaults.headers.common['Authorization'])
1580
+ .toBe('Bearer my-jwt-token');
1581
+ });
1582
+ });
1583
+
1584
+ describe('setupAuthentication — BASIC_API_KEY', () => {
1585
+ test('should set API-Key header when user is not apikey', async () => {
1586
+ const svc = new RestService({
1587
+ baseUrl: 'http://x/api/v1', apiKey: 'my-api-key'
1588
+ });
1589
+ await (svc as any).setupAuthentication();
1590
+ expect(mockAxiosInstance.defaults.headers.common['API-Key']).toBe('my-api-key');
1591
+ });
1592
+
1593
+ test('should set Basic auth with apikey:key when user is apikey', async () => {
1594
+ const svc = new RestService({
1595
+ baseUrl: 'http://x/api/v1', apiKey: 'my-api-key', user: 'apikey'
1596
+ });
1597
+ await (svc as any).setupAuthentication();
1598
+ const expected = 'Basic ' + Buffer.from('apikey:my-api-key').toString('base64');
1599
+ expect(mockAxiosInstance.defaults.headers.common['Authorization']).toBe(expected);
1600
+ });
1601
+ });
1602
+
1603
+ describe('setupAuthentication — WIA', () => {
1604
+ test('should throw for Windows Integrated Authentication', async () => {
1605
+ const svc = new RestService({
1606
+ address: 'host', ssl: true, integratedLogin: true
1607
+ });
1608
+ await expect((svc as any).setupAuthentication())
1609
+ .rejects.toThrow(/Windows Integrated Authentication.*not supported/);
1610
+ });
1611
+ });
1612
+
1613
+ describe('verify propagation to external auth requests', () => {
1614
+ test('should pass rejectUnauthorized:false to IAM request when verify is false', async () => {
1615
+ (axios.post as jest.Mock).mockResolvedValue({
1616
+ data: { access_token: 'token' }
1617
+ });
1618
+ const svc = new RestService({
1619
+ address: 'pa.ibm.com', tenant: 'T', database: 'D',
1620
+ iamUrl: 'https://iam', ssl: true, apiKey: 'k',
1621
+ verify: false
1622
+ });
1623
+ await (svc as any)._generateIbmIamCloudAccessToken();
1624
+ const callArgs = (axios.post as jest.Mock).mock.calls[0][2];
1625
+ expect(callArgs.httpsAgent).toBeDefined();
1626
+ });
1627
+
1628
+ test('should pass rejectUnauthorized:false to S2S request when verify is false', async () => {
1629
+ (axios.post as jest.Mock).mockResolvedValue({
1630
+ status: 200,
1631
+ headers: { 'set-cookie': ['TM1SessionId=s; Path=/'] }
1632
+ });
1633
+ const svc = new RestService({
1634
+ address: 'h', instance: 'I', database: 'D', ssl: true,
1635
+ applicationClientId: 'id', applicationClientSecret: 'secret',
1636
+ user: 'admin', verify: false
1637
+ });
1638
+ await (svc as any)._authenticateServiceToService();
1639
+ const callArgs = (axios.post as jest.Mock).mock.calls[0][2];
1640
+ expect(callArgs.httpsAgent).toBeDefined();
1641
+ });
1642
+
1643
+ test('should pass rejectUnauthorized:false to CPD request when verify is false', async () => {
1644
+ (axios.post as jest.Mock).mockResolvedValue({
1645
+ data: { token: 'jwt' }
1646
+ });
1647
+ const svc = new RestService({
1648
+ address: 'h', user: 'u', password: 'p',
1649
+ paUrl: 'https://pa', database: 'db', ssl: true,
1650
+ cpdUrl: 'https://cpd', verify: false
1651
+ });
1652
+ await (svc as any)._generateCpdAccessToken({ username: 'u', password: 'p' });
1653
+ const callArgs = (axios.post as jest.Mock).mock.calls[0][2];
1654
+ expect(callArgs.httpsAgent).toBeDefined();
1655
+ });
1656
+ });
1657
+
1658
+ describe('issue #81 — admin checks, utility helpers, reconnect config', () => {
1659
+ let svcMock: any;
1660
+ let onError: ((error: any) => Promise<any>) | undefined;
1661
+
1662
+ const buildService = (extra: Record<string, any> = {}) => {
1663
+ svcMock = Object.assign(jest.fn(), {
1664
+ get: jest.fn(),
1665
+ post: jest.fn(),
1666
+ patch: jest.fn(),
1667
+ put: jest.fn(),
1668
+ delete: jest.fn(),
1669
+ request: jest.fn(),
1670
+ defaults: { headers: { common: {} as Record<string, string> } },
1671
+ interceptors: {
1672
+ request: { use: jest.fn() },
1673
+ response: {
1674
+ use: jest.fn((_onFulfilled: any, onRejected: any) => {
1675
+ onError = onRejected;
1676
+ return 0;
1677
+ })
1678
+ }
1679
+ }
1680
+ });
1681
+ mockedAxios.create.mockReturnValue(svcMock);
1682
+
1683
+ return new RestService({
1684
+ baseUrl: 'http://localhost:8879/api/v1',
1685
+ user: 'bob',
1686
+ password: 'pw',
1687
+ timeout: 60,
1688
+ ...extra
1689
+ });
1690
+ };
1691
+
1692
+ test('caches is_admin and only calls /ActiveUser/Groups once', async () => {
1693
+ const svc = buildService();
1694
+ svcMock.request.mockResolvedValue(
1695
+ createMockResponse({ value: [{ Name: 'ADMIN' }] })
1696
+ );
1697
+
1698
+ const first = await svc.is_admin();
1699
+ const second = await svc.is_admin();
1700
+
1701
+ expect(first).toBe(true);
1702
+ expect(second).toBe(true);
1703
+ expect(svcMock.request).toHaveBeenCalledTimes(1);
1704
+ });
1705
+
1706
+ test('is_admin returns false when ADMIN group not present', async () => {
1707
+ const svc = buildService();
1708
+ svcMock.request.mockResolvedValue(
1709
+ createMockResponse({ value: [{ Name: 'Users' }] })
1710
+ );
1711
+
1712
+ expect(await svc.is_admin()).toBe(false);
1713
+ });
1714
+
1715
+ test('is_admin matches ADMIN case-insensitively in returned group names', async () => {
1716
+ const svc = buildService();
1717
+ svcMock.request.mockResolvedValue(
1718
+ createMockResponse({ value: [{ Name: 'admin' }] })
1719
+ );
1720
+
1721
+ expect(await svc.is_admin()).toBe(true);
1722
+ });
1723
+
1724
+ test('pre-populates all admin flags when configured user is ADMIN (any casing)', async () => {
1725
+ for (const user of ['ADMIN', 'admin', 'Ad Min']) {
1726
+ const svc = buildService({ user });
1727
+
1728
+ expect(await svc.is_admin()).toBe(true);
1729
+ expect(await svc.is_data_admin()).toBe(true);
1730
+ expect(await svc.is_security_admin()).toBe(true);
1731
+ expect(await svc.is_ops_admin()).toBe(true);
1732
+ expect(svcMock.request).not.toHaveBeenCalled();
1733
+ }
1734
+ });
1735
+
1736
+ test('is_data_admin matches Admin or DataAdmin case+space insensitively', async () => {
1737
+ const svc = buildService();
1738
+ svcMock.request.mockResolvedValue(
1739
+ createMockResponse({ value: [{ Name: 'Data Admin' }] })
1740
+ );
1741
+
1742
+ expect(await svc.is_data_admin()).toBe(true);
1743
+ });
1744
+
1745
+ test('is_security_admin matches SecurityAdmin', async () => {
1746
+ const svc = buildService();
1747
+ svcMock.request.mockResolvedValue(
1748
+ createMockResponse({ value: [{ Name: 'securityadmin' }] })
1749
+ );
1750
+
1751
+ expect(await svc.is_security_admin()).toBe(true);
1752
+ });
1753
+
1754
+ test('is_ops_admin matches OperationsAdmin', async () => {
1755
+ const svc = buildService();
1756
+ svcMock.request.mockResolvedValue(
1757
+ createMockResponse({ value: [{ Name: 'Operations Admin' }] })
1758
+ );
1759
+
1760
+ expect(await svc.is_ops_admin()).toBe(true);
1761
+ });
1762
+
1763
+ test('admin checks propagate errors instead of swallowing them', async () => {
1764
+ const svc = buildService();
1765
+ svcMock.request.mockRejectedValue(new TM1RestException('boom', 500));
1766
+
1767
+ await expect(svc.is_admin()).rejects.toThrow('boom');
1768
+ });
1769
+
1770
+ test('sync isAdmin/isDataAdmin/isSecurityAdmin/isOpsAdmin getters reflect cached state', async () => {
1771
+ const svc = buildService();
1772
+ // Before any is_*() call resolves, all sync getters return false.
1773
+ expect(svc.isAdmin).toBe(false);
1774
+ expect(svc.isDataAdmin).toBe(false);
1775
+ expect(svc.isSecurityAdmin).toBe(false);
1776
+ expect(svc.isOpsAdmin).toBe(false);
1777
+
1778
+ svcMock.request.mockResolvedValue(
1779
+ createMockResponse({ value: [{ Name: 'ADMIN' }] })
1780
+ );
1781
+ await svc.is_admin();
1782
+ await svc.is_data_admin();
1783
+ await svc.is_security_admin();
1784
+ await svc.is_ops_admin();
1785
+
1786
+ expect(svc.isAdmin).toBe(true);
1787
+ expect(svc.isDataAdmin).toBe(true);
1788
+ expect(svc.isSecurityAdmin).toBe(true);
1789
+ expect(svc.isOpsAdmin).toBe(true);
1790
+ });
1791
+
1792
+ test('concurrent is_*_admin() calls coalesce onto a single /ActiveUser/Groups request', async () => {
1793
+ const svc = buildService();
1794
+ // Non-ADMIN user so pre-populated fast-path does not apply.
1795
+ svcMock.request.mockResolvedValue(
1796
+ createMockResponse({ value: [{ Name: 'Users' }] })
1797
+ );
1798
+
1799
+ const [a, b, c, d] = await Promise.all([
1800
+ svc.is_admin(),
1801
+ svc.is_data_admin(),
1802
+ svc.is_security_admin(),
1803
+ svc.is_ops_admin()
1804
+ ]);
1805
+
1806
+ expect([a, b, c, d]).toEqual([false, false, false, false]);
1807
+ expect(svcMock.request).toHaveBeenCalledTimes(1);
1808
+ });
1809
+
1810
+ test('failed in-flight fetch does not poison subsequent calls', async () => {
1811
+ const svc = buildService();
1812
+ svcMock.request.mockRejectedValueOnce(new TM1RestException('boom', 500));
1813
+ await expect(svc.is_admin()).rejects.toThrow('boom');
1814
+
1815
+ // In-flight promise cleared on rejection; next call hits a fresh request.
1816
+ svcMock.request.mockResolvedValueOnce(
1817
+ createMockResponse({ value: [{ Name: 'ADMIN' }] })
1818
+ );
1819
+ expect(await svc.is_admin()).toBe(true);
1820
+ });
1821
+
1822
+ test('b64_decode_password roundtrips Base64 to UTF-8', () => {
1823
+ const secret = 'p@ssw0rd_äß';
1824
+ const encoded = Buffer.from(secret, 'utf-8').toString('base64');
1825
+
1826
+ expect(RestService.b64_decode_password(encoded)).toBe(secret);
1827
+ });
1828
+
1829
+ test('translate_to_boolean handles booleans, numbers, and strings', () => {
1830
+ expect(RestService.translate_to_boolean(true)).toBe(true);
1831
+ expect(RestService.translate_to_boolean(false)).toBe(false);
1832
+ expect(RestService.translate_to_boolean(1)).toBe(true);
1833
+ expect(RestService.translate_to_boolean(0)).toBe(false);
1834
+ expect(RestService.translate_to_boolean('True')).toBe(true);
1835
+ expect(RestService.translate_to_boolean(' TRUE ')).toBe(true);
1836
+ expect(RestService.translate_to_boolean('false')).toBe(false);
1837
+ expect(RestService.translate_to_boolean('no')).toBe(false);
1838
+ });
1839
+
1840
+ test('translate_to_boolean throws on invalid types', () => {
1841
+ expect(() => RestService.translate_to_boolean(null)).toThrow(/Invalid argument/);
1842
+ expect(() => RestService.translate_to_boolean(undefined)).toThrow(/Invalid argument/);
1843
+ expect(() => RestService.translate_to_boolean({})).toThrow(/Invalid argument/);
1844
+ });
1845
+
1846
+ test('add_compact_json_header inserts tm1.compact=v0 at position 1 and returns original', () => {
1847
+ const svc = buildService();
1848
+ const accept = 'application/json;odata.metadata=none,text/plain';
1849
+ svcMock.defaults.headers.common['Accept'] = accept;
1850
+
1851
+ const original = svc.add_compact_json_header();
1852
+
1853
+ expect(original).toBe(accept);
1854
+ expect(svcMock.defaults.headers.common['Accept']).toBe(
1855
+ 'application/json;tm1.compact=v0;odata.metadata=none,text/plain'
1856
+ );
1857
+ });
1858
+
1859
+ test('skips 401 reauth when reConnectOnSessionTimeout is false', async () => {
1860
+ const svc = buildService({ reConnectOnSessionTimeout: false });
1861
+ (svc as any).isConnected = true;
1862
+ const reAuthSpy = jest.spyOn(svc, 'reAuthenticate');
1863
+
1864
+ const error401: any = {
1865
+ response: { status: 401, statusText: 'Unauthorized', data: {} },
1866
+ config: { _idempotent: true }
1867
+ };
1868
+
1869
+ await expect(onError!(error401)).rejects.toBeInstanceOf(TM1RestException);
1870
+ expect(reAuthSpy).not.toHaveBeenCalled();
1871
+ });
1872
+
1873
+ test('skips connection-error retry when reConnectOnRemoteDisconnect is false', async () => {
1874
+ const svc = buildService({ reConnectOnRemoteDisconnect: false });
1875
+ (svc as any).isConnected = true;
1876
+
1877
+ const networkError: any = { code: 'ECONNRESET', message: 'reset', config: { _idempotent: true } };
1878
+
1879
+ await expect(onError!(networkError)).rejects.toBeInstanceOf(TM1RestException);
1880
+ expect(svcMock).not.toHaveBeenCalled();
1881
+ });
1882
+
1883
+ test('respects remoteDisconnectMaxRetries: stops retrying once cap reached', async () => {
1884
+ const svc = buildService({ remoteDisconnectMaxRetries: 1 });
1885
+ (svc as any).isConnected = true;
1886
+
1887
+ // canRetryRequest is gated on the per-request _retryCount; pre-seed to the cap
1888
+ // so the next retry attempt is rejected and the error propagates as TM1RestException.
1889
+ const requestConfig: any = { _idempotent: true, _retryCount: 1 };
1890
+ const networkError: any = { code: 'ECONNRESET', message: 'reset', config: requestConfig };
1891
+
1892
+ await expect(onError!(networkError)).rejects.toBeInstanceOf(TM1RestException);
1893
+ expect(svcMock).not.toHaveBeenCalled();
1894
+ });
1895
+
1896
+ test('retryRequest clears sessionCookies so connect() re-runs setupAuthentication', async () => {
1897
+ // Mirrors tm1py's _handle_remote_disconnect → connect() flow where
1898
+ // connect() always re-runs auth regardless of any prior cookie
1899
+ // state. Without clearing, connect()'s getSessionCookieValue check
1900
+ // would skip setupAuthentication and reuse a stale cookie.
1901
+ const svc = buildService({ remoteDisconnectMaxRetries: 1, remoteDisconnectRetryDelay: 0 });
1902
+ (svc as any).isConnected = true;
1903
+ (svc as any).sessionCookies.set('TM1SessionId', 'stale-cookie');
1904
+
1905
+ const authSpy = jest.fn().mockResolvedValue(undefined);
1906
+ (svc as any).setupAuthentication = authSpy;
1907
+
1908
+ // Probe GET inside connect() succeeds; replayed request succeeds too.
1909
+ svcMock.mockResolvedValue({ status: 200, data: {} });
1910
+ svcMock.get.mockResolvedValue({ status: 200, data: { value: 'Server1' } });
1911
+
1912
+ const requestConfig: any = { _idempotent: true, headers: { Cookie: 'stale-cookie' } };
1913
+ const networkError: any = { code: 'ECONNRESET', message: 'reset', config: requestConfig };
1914
+
1915
+ await onError!(networkError);
1916
+
1917
+ // sessionCookies cleared before connect() so setupAuthentication runs.
1918
+ expect(authSpy).toHaveBeenCalledTimes(1);
1919
+ // Stale Cookie header on replayed config was stripped.
1920
+ expect(requestConfig.headers.Cookie).toBeUndefined();
1921
+ });
1922
+ });
1923
+ });