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.
- package/CHANGELOG.md +89 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/services/ApplicationService.d.ts +19 -3
- package/lib/services/ApplicationService.d.ts.map +1 -1
- package/lib/services/ApplicationService.js +232 -6
- package/lib/services/AsyncOperationService.d.ts +8 -1
- package/lib/services/AsyncOperationService.d.ts.map +1 -1
- package/lib/services/AsyncOperationService.js +69 -26
- package/lib/services/ElementService.d.ts +67 -1
- package/lib/services/ElementService.d.ts.map +1 -1
- package/lib/services/ElementService.js +214 -0
- package/lib/services/FileService.d.ts.map +1 -1
- package/lib/services/HierarchyService.d.ts +26 -0
- package/lib/services/HierarchyService.d.ts.map +1 -1
- package/lib/services/HierarchyService.js +306 -0
- package/lib/services/ProcessService.d.ts +40 -22
- package/lib/services/ProcessService.d.ts.map +1 -1
- package/lib/services/ProcessService.js +118 -111
- package/lib/services/RestService.d.ts +213 -25
- package/lib/services/RestService.d.ts.map +1 -1
- package/lib/services/RestService.js +841 -263
- package/lib/services/SubsetService.d.ts +2 -0
- package/lib/services/SubsetService.d.ts.map +1 -1
- package/lib/services/SubsetService.js +33 -0
- package/lib/services/TM1Service.d.ts +44 -1
- package/lib/services/TM1Service.d.ts.map +1 -1
- package/lib/services/TM1Service.js +96 -4
- package/lib/services/index.d.ts +1 -1
- package/lib/services/index.d.ts.map +1 -1
- package/lib/tests/100PercentParityCheck.test.js +23 -6
- package/lib/tests/applicationService.issue38.test.d.ts +5 -0
- package/lib/tests/applicationService.issue38.test.d.ts.map +1 -0
- package/lib/tests/applicationService.issue38.test.js +237 -0
- package/lib/tests/asyncOperationService.test.js +51 -45
- package/lib/tests/bugfix28.test.js +12 -4
- package/lib/tests/elementService.issue37.test.d.ts +5 -0
- package/lib/tests/elementService.issue37.test.d.ts.map +1 -0
- package/lib/tests/elementService.issue37.test.js +413 -0
- package/lib/tests/elementService.issue38.test.d.ts +5 -0
- package/lib/tests/elementService.issue38.test.d.ts.map +1 -0
- package/lib/tests/elementService.issue38.test.js +79 -0
- package/lib/tests/hierarchyService.issue38.test.d.ts +5 -0
- package/lib/tests/hierarchyService.issue38.test.d.ts.map +1 -0
- package/lib/tests/hierarchyService.issue38.test.js +460 -0
- package/lib/tests/processService.comprehensive.test.js +9 -9
- package/lib/tests/processService.test.js +234 -0
- package/lib/tests/restService.test.d.ts +0 -4
- package/lib/tests/restService.test.d.ts.map +1 -1
- package/lib/tests/restService.test.js +1558 -143
- package/lib/tests/subsetService.issue38.test.d.ts +5 -0
- package/lib/tests/subsetService.issue38.test.d.ts.map +1 -0
- package/lib/tests/subsetService.issue38.test.js +113 -0
- package/lib/tests/tm1Service.test.js +80 -8
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/services/ApplicationService.ts +282 -10
- package/src/services/AsyncOperationService.ts +76 -29
- package/src/services/ElementService.ts +322 -1
- package/src/services/FileService.ts +3 -3
- package/src/services/HierarchyService.ts +419 -1
- package/src/services/ProcessService.ts +185 -142
- package/src/services/RestService.ts +1021 -267
- package/src/services/SubsetService.ts +48 -0
- package/src/services/TM1Service.ts +127 -6
- package/src/services/index.ts +1 -1
- package/src/tests/100PercentParityCheck.test.ts +29 -8
- package/src/tests/applicationService.issue38.test.ts +293 -0
- package/src/tests/asyncOperationService.test.ts +52 -48
- package/src/tests/bugfix28.test.ts +12 -4
- package/src/tests/elementService.issue37.test.ts +571 -0
- package/src/tests/elementService.issue38.test.ts +103 -0
- package/src/tests/hierarchyService.issue38.test.ts +599 -0
- package/src/tests/processService.comprehensive.test.ts +10 -10
- package/src/tests/processService.test.ts +295 -3
- package/src/tests/restService.test.ts +1844 -139
- package/src/tests/subsetService.issue38.test.ts +182 -0
- 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
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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: {
|
|
38
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
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
|
-
|
|
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('
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
500
|
+
describe('buildCookieHeader', () => {
|
|
501
|
+
test('empty store returns undefined', () => {
|
|
502
|
+
const { svc } = makeSvc();
|
|
503
|
+
expect((svc as any).buildCookieHeader()).toBeUndefined();
|
|
504
|
+
});
|
|
68
505
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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('
|
|
119
|
-
test('should
|
|
120
|
-
|
|
121
|
-
expect(
|
|
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
|
|
125
|
-
|
|
126
|
-
expect(
|
|
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('
|
|
131
|
-
test('should
|
|
132
|
-
const
|
|
133
|
-
(
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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('
|
|
178
|
-
test('should
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
expect(
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
+
});
|