@contrail/flexplm 1.7.7-alpha.d6a63ad → 1.7.7-alpha.e66ae52

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.
@@ -6,6 +6,10 @@ export interface FCConfig {
6
6
  eventObjectClassParam?: string;
7
7
  eventFlexTypePathParam?: string;
8
8
  flexplmConnect?: any;
9
+ 'flexplmConnect.sessionReuse.enabled'?: boolean | string;
10
+ 'flexplmConnect.sessionReuse.ttlSeconds'?: number | string;
11
+ 'flexplmConnect.sessionReuse.cookieName'?: string;
12
+ 'flexplmConnect.sessionReuse.retryStatuses'?: number | number[] | string;
9
13
  taskId?: string;
10
14
  apiHost: string;
11
15
  userName(): string;
@@ -6,7 +6,17 @@ export declare class FlexPLMConnect {
6
6
  payloadSendAsArray: boolean;
7
7
  constructor(_config: FCConfig, endPoint?: any, payloadAsArray?: any);
8
8
  private get sessionOptions();
9
+ /**
10
+ * Waits until no cold session acquisition is running for this key.
11
+ * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
12
+ * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
13
+ */
14
+ private waitForInFlightAcquisition;
15
+ /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
16
+ private runColdAcquisition;
9
17
  private getSessionKey;
18
+ /** Releases the connection of a rejected response before it is replaced by a retry. */
19
+ private discardBody;
10
20
  private getCachedSession;
11
21
  private storeSession;
12
22
  private clearSession;
@@ -14,11 +24,8 @@ export declare class FlexPLMConnect {
14
24
  private extractSessionCookie;
15
25
  private cookieHeader;
16
26
  private getRequestOptions;
17
- getCSRF(getNewSession?: boolean): Promise<{
18
- nonce_key: any;
19
- nonce: any;
20
- sessionCookie: string;
21
- }>;
27
+ getCSRF(getNewSession?: boolean, rejectedCookie?: string): any;
28
+ private fetchCSRF;
22
29
  sendRequest(csrf: any, payload: any): Promise<Response>;
23
30
  protected processRequest(payload: any): Promise<FlexPLMResponseData>;
24
31
  sendToFlexPLM(payload: PayloadType): Promise<FlexPLMResponseData>;
@@ -2,8 +2,42 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FlexPLMConnect = void 0;
4
4
  const app_framework_1 = require("@contrail/app-framework");
5
- // Module-level on purpose: a new FlexPLMConnect is constructed for every event. Declaring for Session resuing use case.
5
+ // Module-level on purpose: a new FlexPLMConnect is constructed for every event. Declaring for Session reusing use case.
6
6
  const sessionCache = new Map();
7
+ // In-flight cold session acquisitions per key, so concurrent events share one FlexPLM session instead of each creating their own.
8
+ const inFlightAcquisitions = new Map();
9
+ const DEFAULT_SESSION_COOKIE_NAME = 'JSESSIONID';
10
+ const DEFAULT_SESSION_TTL_SECONDS = 900;
11
+ const DEFAULT_RETRY_STATUSES = [401, 403];
12
+ const COOKIE_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
13
+ // Thrown internally when a cached cookie was rejected, so the caller can re-acquire under the lock.
14
+ class SessionRejectedError extends Error {
15
+ }
16
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ const parseRetryStatuses = (value) => {
18
+ let list = [];
19
+ if (Array.isArray(value))
20
+ list = value;
21
+ else if (typeof value === 'string')
22
+ list = value.split(',');
23
+ else if (value !== undefined && value !== null)
24
+ list = [value];
25
+ const statuses = list
26
+ .map(status => (typeof status === 'string' ? status.trim() : status))
27
+ .filter(status => status !== '' && status !== null && status !== undefined)
28
+ .map(Number)
29
+ // Only client-error statuses can mean "session rejected". 2xx/5xx would resend events the server already processed.
30
+ .filter(status => Number.isInteger(status) && status >= 400 && status <= 499);
31
+ return statuses.length ? statuses : DEFAULT_RETRY_STATUSES;
32
+ };
33
+ const parseTtlSeconds = (value) => {
34
+ const ttl = Number(value);
35
+ return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SECONDS;
36
+ };
37
+ const parseCookieName = (value) => {
38
+ const name = typeof value === 'string' ? value.trim() : '';
39
+ return COOKIE_NAME_TOKEN.test(name) ? name : DEFAULT_SESSION_COOKIE_NAME;
40
+ };
7
41
  // Added for Session reuse - ET-390 - Ends
8
42
  class FlexPLMConnect {
9
43
  constructor(_config, endPoint = undefined, payloadAsArray = undefined) {
@@ -24,17 +58,76 @@ class FlexPLMConnect {
24
58
  /////////////////////////////////////////////////////////////////////////////
25
59
  ///////// ET-390 - Session reuse: start
26
60
  /////////////////////////////////////////////////////////////////////////////
61
+ /*
62
+ private get sessionOptions() {
63
+ const sessReuseBool = this.config?.flexplmConnect?.sessionReuse;
64
+ console.info('sessReuseBool value is --> ' + sessReuseBool);
65
+ console.info('enabled value is --> ' + sessReuseBool?.enabled);
66
+ return {
67
+ enabled: sessReuseBool?.enabled === true || String(sessReuseBool?.enabled).toLowerCase() === 'true',
68
+ ttlMs: (sessReuseBool?.ttlSeconds ?? 900) * 1000,
69
+ cookieName: sessReuseBool?.cookieName ?? 'JSESSIONID',
70
+ retryStatuses: (sessReuseBool?.retryStatuses ?? [401, 403]) as number[],
71
+ };
72
+ }
73
+ */
27
74
  get sessionOptions() {
28
- const sessReuseBool = this.config?.flexplmConnect?.sessionReuse;
75
+ const cfg = this.config ?? {};
76
+ const sessReuseEntries = (name) => cfg[`flexplmConnect.sessionReuse.${name}`];
77
+ const enabled = sessReuseEntries('enabled');
29
78
  return {
30
- enabled: sessReuseBool?.enabled === true || String(sessReuseBool?.enabled).toLowerCase() === 'true',
31
- ttlMs: (sessReuseBool?.ttlSeconds ?? 900) * 1000,
32
- cookieName: sessReuseBool?.cookieName ?? 'JSESSIONID',
33
- retryStatuses: (sessReuseBool?.retryStatuses ?? [401, 403]),
79
+ enabled: enabled === true || String(enabled).toLowerCase() === 'true',
80
+ ttlMs: parseTtlSeconds(sessReuseEntries('ttlSeconds')) * 1000,
81
+ cookieName: parseCookieName(sessReuseEntries('cookieName')),
82
+ retryStatuses: parseRetryStatuses(sessReuseEntries('retryStatuses')),
34
83
  };
35
84
  }
85
+ /**
86
+ * Waits until no cold session acquisition is running for this key.
87
+ * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
88
+ * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
89
+ */
90
+ waitForInFlightAcquisition() {
91
+ const key = this.getSessionKey();
92
+ if (!inFlightAcquisitions.has(key))
93
+ return undefined;
94
+ return (async () => {
95
+ let pending = inFlightAcquisitions.get(key);
96
+ while (pending) {
97
+ await pending.catch(() => undefined);
98
+ pending = inFlightAcquisitions.get(key);
99
+ }
100
+ })();
101
+ }
102
+ /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
103
+ async runColdAcquisition(acquire) {
104
+ const key = this.getSessionKey();
105
+ const acquisition = acquire();
106
+ inFlightAcquisitions.set(key, acquisition);
107
+ try {
108
+ return await acquisition;
109
+ }
110
+ finally {
111
+ if (inFlightAcquisitions.get(key) === acquisition)
112
+ inFlightAcquisitions.delete(key);
113
+ }
114
+ }
36
115
  getSessionKey() {
37
- return [this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment].join('|');
116
+ // Options that change the cached value are part of the key; JSON avoids '|' collisions between fields.
117
+ const { cookieName, ttlMs } = this.sessionOptions;
118
+ return JSON.stringify([
119
+ this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment, cookieName, ttlMs,
120
+ ]);
121
+ }
122
+ /** Releases the connection of a rejected response before it is replaced by a retry. */
123
+ async discardBody(response) {
124
+ try {
125
+ if (typeof response?.body?.cancel === 'function')
126
+ await response.body.cancel();
127
+ }
128
+ catch (e) {
129
+ // best effort: nothing to do if the body is already consumed or locked
130
+ }
38
131
  }
39
132
  getCachedSession() {
40
133
  if (!this.sessionOptions.enabled)
@@ -69,7 +162,7 @@ class FlexPLMConnect {
69
162
  raw = [respHeader.get('set-cookie')];
70
163
  }
71
164
  const name = this.sessionOptions.cookieName;
72
- const regexFilter = new RegExp('(?:^|[,\\s])' + name + '=([^;,\\s]+)', 'g');
165
+ const regexFilter = new RegExp('(?:^|[,\\s])' + escapeRegExp(name) + '=([^;,\\s]+)', 'g');
73
166
  let jSession;
74
167
  for (const line of raw) {
75
168
  let cookieNameMatcher = regexFilter.exec(line);
@@ -124,26 +217,48 @@ class FlexPLMConnect {
124
217
  /////////////////////////////////////////////////////////////////////////////
125
218
  // ET-390 - adding a new parameter getNewSession to the getCSRF function
126
219
  // Whenever a getCSRF is called without getNewSession, the default value of false will be used
127
- async getCSRF(getNewSession = false) {
220
+ // rejectedCookie: the cookie that was rejected by the caller, so a refresh already done by a concurrent event is not thrown away
221
+ async getCSRF(getNewSession = false, rejectedCookie) {
222
+ // Added for Session reuse - ET-390 - Starts
223
+ if (!this.sessionOptions.enabled)
224
+ return this.fetchCSRF(undefined);
225
+ for (let wait = this.waitForInFlightAcquisition(); wait; wait = this.waitForInFlightAcquisition())
226
+ await wait;
227
+ if (getNewSession) {
228
+ const current = this.getCachedSession();
229
+ if (!current || current === rejectedCookie)
230
+ this.clearSession();
231
+ }
232
+ const cached = this.getCachedSession();
233
+ if (cached) {
234
+ try {
235
+ return await this.fetchCSRF(cached);
236
+ }
237
+ catch (e) {
238
+ if (!(e instanceof SessionRejectedError))
239
+ throw e;
240
+ // if the cached cookie is rejected (401/403), drop it and acquire a new session (serialized)
241
+ if (this.getCachedSession() === cached)
242
+ this.clearSession();
243
+ return this.getCSRF();
244
+ }
245
+ }
246
+ return this.runColdAcquisition(() => this.fetchCSRF(undefined));
247
+ // Added for Session reuse - ET-390 - Ends
248
+ }
249
+ async fetchCSRF(sentCookie) {
128
250
  const urlContext = this.config.urlContext;
129
251
  const csrfEndpoint = this.config.csrfEndpoint;
130
252
  const csrfURL = this.config.apiHost + urlContext + csrfEndpoint;
131
253
  console.info('csrfURL: ' + csrfURL);
132
- // Added for Session reuse - ET-390 - Starts
133
- if (getNewSession)
134
- this.clearSession();
135
- let sentCookie = this.getCachedSession();
136
254
  // getRequestOptions will be called with additional sentCookie parameter
137
255
  const csrfOptions = this.getRequestOptions('GET', sentCookie);
138
- // Added for Session reuse - ET-390 - Ends
139
- let response = await fetch(csrfURL, csrfOptions);
256
+ const response = await fetch(csrfURL, csrfOptions);
140
257
  // Added for Session reuse - ET-390 - Starts
141
- // if the response status is 401 or 403, then get a new session id
142
258
  if (sentCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
143
259
  console.warn('Cached FlexPLM session rejected on csrf call (status ' + response.status + '). Retrying with a new session.');
144
- this.clearSession();
145
- sentCookie = undefined;
146
- response = await fetch(csrfURL, this.getRequestOptions('GET'));
260
+ await this.discardBody(response);
261
+ throw new SessionRejectedError();
147
262
  }
148
263
  // Added for Session reuse - ET-390 - Ends
149
264
  if (response.status >= 300) {
@@ -167,12 +282,11 @@ class FlexPLMConnect {
167
282
  const body = await response.json();
168
283
  const nonce_key = body.items[0].attributes.nonce_key;
169
284
  const nonce = body.items[0].attributes.nonce;
170
- console.info('nonce_key: ' + nonce_key);
171
- //ET-390 - adding sessionCookie to the return
285
+ // ET-390 - adding sessionCookie to the return
172
286
  return {
173
287
  nonce_key,
174
288
  nonce,
175
- sessionCookie
289
+ sessionCookie,
176
290
  };
177
291
  }
178
292
  catch (e) {
@@ -227,7 +341,8 @@ class FlexPLMConnect {
227
341
  let response = await this.sendRequest(csrf, payload);
228
342
  if (csrf.sessionCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
229
343
  console.warn('FlexPLM rejected session/nonce (status ' + response.status + '). Getting a new session and retrying once.');
230
- csrf = await this.getCSRF(true);
344
+ await this.discardBody(response);
345
+ csrf = await this.getCSRF(true, csrf.sessionCookie);
231
346
  response = await this.sendRequest(csrf, payload);
232
347
  }
233
348
  // Added for Session reuse - ET-390 - Ends
@@ -284,17 +399,32 @@ class FlexPLMConnect {
284
399
  const path = urlPath || ('/servlet/rest' + this.vibeEventEndpoint);
285
400
  const vibeEventsURL = this.config.apiHost + urlContext + path;
286
401
  // Added for Session reuse - ET-390 - Starts
287
- const cached = this.getCachedSession();
288
- const csrfOptions = this.getRequestOptions('GET', cached);
289
- let response = await fetch(vibeEventsURL, csrfOptions);
290
- if (cached && this.sessionOptions.retryStatuses.includes(response.status)) {
291
- this.clearSession();
292
- response = await fetch(vibeEventsURL, this.getRequestOptions('GET'));
293
- }
294
- if (this.sessionOptions.enabled) {
295
- const cachedCookie = this.extractSessionCookie(response) ?? cached;
296
- if (cachedCookie)
297
- this.storeSession(cachedCookie);
402
+ const attempt = async () => {
403
+ if (!this.sessionOptions.enabled) {
404
+ return { response: await fetch(vibeEventsURL, this.getRequestOptions('GET')), sent: undefined };
405
+ }
406
+ for (let wait = this.waitForInFlightAcquisition(); wait; wait = this.waitForInFlightAcquisition())
407
+ await wait;
408
+ const sent = this.getCachedSession();
409
+ const call = async () => {
410
+ const res = await fetch(vibeEventsURL, this.getRequestOptions('GET', sent));
411
+ // A rejected cookie must not be stored again; only keep a cookie that was accepted or newly issued.
412
+ if (!(sent && this.sessionOptions.retryStatuses.includes(res.status))) {
413
+ const cookie = this.extractSessionCookie(res) ?? sent;
414
+ if (cookie)
415
+ this.storeSession(cookie);
416
+ }
417
+ return { response: res, sent };
418
+ };
419
+ return sent ? call() : this.runColdAcquisition(call);
420
+ };
421
+ const first = await attempt();
422
+ let response = first.response;
423
+ if (first.sent && this.sessionOptions.retryStatuses.includes(response.status)) {
424
+ await this.discardBody(response);
425
+ if (this.getCachedSession() === first.sent)
426
+ this.clearSession();
427
+ response = (await attempt()).response;
298
428
  }
299
429
  // Added for Session reuse - ET-390 - Ends
300
430
  if (response.status >= 300) {
@@ -39,7 +39,12 @@ const resp = (status, setCookie = [], json = csrfBody) => ({
39
39
  });
40
40
  let userCounter = 0;
41
41
  const uniqueUser = () => 'sess-user-' + (++userCounter);
42
- const sessionConfig = (user, sessionReuse = { enabled: true }) => createConfig({ userName: () => user, flexplmConnect: { sessionReuse } });
42
+ // The connector config is flat: keys look like 'flexplmConnect.sessionReuse.enabled'
43
+ const sessionConfig = (user, sessionReuse = { enabled: true }) => {
44
+ const flat = {};
45
+ Object.entries(sessionReuse).forEach(([k, v]) => { flat[`flexplmConnect.sessionReuse.${k}`] = v; });
46
+ return createConfig({ userName: () => user, ...flat });
47
+ };
43
48
  const fetchMock = () => global.fetch;
44
49
  const callHeaders = (i) => fetchMock().mock.calls[i][1].headers;
45
50
  const callMethod = (i) => fetchMock().mock.calls[i][1].method;
@@ -140,7 +145,7 @@ describe('FlexPLMConnect.sendRequest', () => {
140
145
  // Added for Session reuse - ET-390 - Starts
141
146
  describe('FlexPLMConnect session reuse', () => {
142
147
  const payload = { id: '123' };
143
- it('captures JSESSIONID from the csrf GET and sends it on the POST', async () => {
148
+ it('should capture JSESSIONID from the CSRF GET and send it on the POST', async () => {
144
149
  const u = uniqueUser();
145
150
  fetchMock()
146
151
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/; HttpOnly']))
@@ -321,6 +326,224 @@ describe('FlexPLMConnect session reuse', () => {
321
326
  isInfoOn.mockRestore();
322
327
  info.mockRestore();
323
328
  });
329
+ describe('option normalisation', () => {
330
+ const rejectedOnceThenOk = (u, opts) => {
331
+ fetchMock()
332
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
333
+ .mockResolvedValueOnce(resp(403, [], {}))
334
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
335
+ .mockResolvedValueOnce(resp(200, [], {}));
336
+ return new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ...opts })).sendToFlexPLM(payload);
337
+ };
338
+ it.each([
339
+ ['a scalar number', 403],
340
+ ['a comma separated string', '401, 403'],
341
+ ['garbage (falls back to 401/403)', 'abc'],
342
+ ['an empty array (falls back to 401/403)', []],
343
+ ])('retryStatuses given as %s does not throw and still retries on 403', async (_label, retryStatuses) => {
344
+ await rejectedOnceThenOk(uniqueUser(), { retryStatuses });
345
+ expect(fetchMock()).toHaveBeenCalledTimes(4);
346
+ });
347
+ it.each([['abc'], [0], [-5]])('invalid ttlSeconds (%p) falls back to the 900s default', async (ttlSeconds) => {
348
+ const u = uniqueUser();
349
+ const realNow = Date.now();
350
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
351
+ fetchMock()
352
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
353
+ .mockResolvedValueOnce(resp(200, [], {}))
354
+ .mockResolvedValueOnce(resp(200, []))
355
+ .mockResolvedValueOnce(resp(200, [], {}));
356
+ const cfg = sessionConfig(u, { enabled: true, ttlSeconds });
357
+ await new flexplm_connect_1.FlexPLMConnect(cfg).sendToFlexPLM(payload);
358
+ nowSpy.mockReturnValue(realNow + 2000);
359
+ await new flexplm_connect_1.FlexPLMConnect(cfg).sendToFlexPLM(payload);
360
+ expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
361
+ nowSpy.mockRestore();
362
+ });
363
+ it('a blank cookieName falls back to JSESSIONID', async () => {
364
+ const u = uniqueUser();
365
+ fetchMock()
366
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
367
+ .mockResolvedValueOnce(resp(200, [], {}));
368
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: ' ' })).sendToFlexPLM(payload);
369
+ expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
370
+ });
371
+ });
372
+ describe('retryStatuses restricted to client-error statuses', () => {
373
+ const postFailsWith = async (status, retryStatuses) => {
374
+ const u = uniqueUser();
375
+ fetchMock()
376
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
377
+ .mockResolvedValueOnce(resp(status, [], {}));
378
+ await expect(new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, retryStatuses })).sendToFlexPLM({ id: '1' }))
379
+ .rejects.toThrow('Error sending data to FlexPLM:status: ' + status);
380
+ expect(fetchMock()).toHaveBeenCalledTimes(2); // no retry
381
+ };
382
+ it.each([[500], ['500'], [[200, 500]]])('should ignore non-4xx retryStatuses (%p) and not retry a 500', async (retryStatuses) => {
383
+ await postFailsWith(500, retryStatuses);
384
+ });
385
+ it('should keep 4xx entries and drop others from a mixed retryStatuses list', async () => {
386
+ await postFailsWith(500, [401, 500]);
387
+ });
388
+ it('should not resend a successful event when retryStatuses is configured as 200', async () => {
389
+ const u = uniqueUser();
390
+ fetchMock()
391
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
392
+ .mockResolvedValueOnce(resp(200, [], {}));
393
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, retryStatuses: 200 })).sendToFlexPLM({ id: '1' });
394
+ expect(fetchMock()).toHaveBeenCalledTimes(2);
395
+ });
396
+ });
397
+ describe('session cache key includes session options', () => {
398
+ it('should not share a cached cookie between different cookieNames for the same user', async () => {
399
+ const u = uniqueUser();
400
+ fetchMock()
401
+ .mockResolvedValueOnce(resp(200, ['AAA=a1; Path=/']))
402
+ .mockResolvedValueOnce(resp(200, [], {}))
403
+ .mockResolvedValueOnce(resp(200, ['BBB=b1; Path=/']))
404
+ .mockResolvedValueOnce(resp(200, [], {}));
405
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'AAA' })).sendToFlexPLM({ id: '1' });
406
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'BBB' })).sendToFlexPLM({ id: '1' });
407
+ expect(callHeaders(2).Cookie).toBeUndefined(); // BBB config must not pick up the AAA session
408
+ expect(callHeaders(3).Cookie).toBe('BBB=b1');
409
+ });
410
+ it('should not share a cached session between different ttlSeconds for the same user', async () => {
411
+ const u = uniqueUser();
412
+ fetchMock()
413
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=short.tomcat1']))
414
+ .mockResolvedValueOnce(resp(200, [], {}))
415
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=long.tomcat1']))
416
+ .mockResolvedValueOnce(resp(200, [], {}));
417
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ttlSeconds: 1 })).sendToFlexPLM({ id: '1' });
418
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ttlSeconds: 900 })).sendToFlexPLM({ id: '1' });
419
+ expect(callHeaders(2).Cookie).toBeUndefined();
420
+ expect(callHeaders(3).Cookie).toBe('JSESSIONID=long.tomcat1');
421
+ });
422
+ });
423
+ describe('rejected response disposal', () => {
424
+ const withBody = (r) => Object.assign(r, { body: { cancel: jest.fn().mockResolvedValue(undefined) } });
425
+ it('should cancel the body of a POST rejected with 403 before retrying', async () => {
426
+ const u = uniqueUser();
427
+ const rejected = withBody(resp(403, [], {}));
428
+ fetchMock()
429
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
430
+ .mockResolvedValueOnce(rejected)
431
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
432
+ .mockResolvedValueOnce(resp(200, [], {}));
433
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
434
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
435
+ });
436
+ it('should cancel the body of a CSRF GET rejected with the cached cookie before retrying', async () => {
437
+ const u = uniqueUser();
438
+ const rejected = withBody(resp(401, []));
439
+ fetchMock()
440
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
441
+ .mockResolvedValueOnce(resp(200, [], {}))
442
+ .mockResolvedValueOnce(rejected)
443
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
444
+ .mockResolvedValueOnce(resp(200, [], {}));
445
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
446
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
447
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
448
+ });
449
+ it('should cancel the body of a getRequest rejected with the cached cookie before retrying', async () => {
450
+ const u = uniqueUser();
451
+ const rejected = withBody(resp(401, []));
452
+ fetchMock()
453
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
454
+ .mockResolvedValueOnce(resp(200, [], {}))
455
+ .mockResolvedValueOnce(rejected)
456
+ .mockResolvedValueOnce(resp(200, [], { items: [] }));
457
+ const connect = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u));
458
+ await connect.sendToFlexPLM({ id: '1' });
459
+ await connect.getRequest();
460
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
461
+ });
462
+ it('should still retry when cancelling the body throws', async () => {
463
+ const u = uniqueUser();
464
+ const rejected = resp(403, [], {});
465
+ rejected.body = { cancel: jest.fn().mockRejectedValue(new Error('locked')) };
466
+ fetchMock()
467
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
468
+ .mockResolvedValueOnce(rejected)
469
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
470
+ .mockResolvedValueOnce(resp(200, [], {}));
471
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
472
+ expect(fetchMock()).toHaveBeenCalledTimes(4);
473
+ });
474
+ });
475
+ describe('cookie names containing regex metacharacters', () => {
476
+ it('extracts a cookie whose name contains "+"', async () => {
477
+ const u = uniqueUser();
478
+ fetchMock()
479
+ .mockResolvedValueOnce(resp(200, ['SID+=abc; Path=/']))
480
+ .mockResolvedValueOnce(resp(200, [], {}));
481
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'SID+' })).sendToFlexPLM(payload);
482
+ expect(callHeaders(1).Cookie).toBe('SID+=abc');
483
+ });
484
+ it('treats "." literally and does not match a look-alike cookie', async () => {
485
+ const u = uniqueUser();
486
+ fetchMock()
487
+ .mockResolvedValueOnce(resp(200, ['AxB=wrong; Path=/']))
488
+ .mockResolvedValueOnce(resp(200, [], {}));
489
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'A.B' })).sendToFlexPLM(payload);
490
+ expect(callHeaders(1).Cookie).toBeUndefined();
491
+ });
492
+ });
493
+ describe('concurrent session acquisition', () => {
494
+ const flush = () => new Promise(r => setImmediate(r));
495
+ const isColdGet = (call) => call[1].method === 'GET' && !call[1].headers.Cookie;
496
+ it('shares one session across concurrent events on a cold cache', async () => {
497
+ const u = uniqueUser();
498
+ let release;
499
+ const gate = new Promise(r => { release = r; });
500
+ fetchMock().mockImplementation(async (_url, opts) => {
501
+ if (opts.method === 'GET' && !opts.headers.Cookie) {
502
+ await gate;
503
+ return resp(200, ['JSESSIONID=abc.tomcat1']);
504
+ }
505
+ return resp(200, []);
506
+ });
507
+ const all = Promise.all([1, 2, 3].map(() => new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF()));
508
+ await flush();
509
+ expect(fetchMock()).toHaveBeenCalledTimes(1); // the other two are waiting for the first acquisition
510
+ release();
511
+ const results = await all;
512
+ expect(fetchMock().mock.calls.filter(isColdGet)).toHaveLength(1);
513
+ results.forEach(r => expect(r.sessionCookie).toBe('abc.tomcat1'));
514
+ });
515
+ it('a waiter does not inherit the failure of the first acquisition', async () => {
516
+ const u = uniqueUser();
517
+ let release;
518
+ const gate = new Promise(r => { release = r; });
519
+ fetchMock()
520
+ .mockImplementationOnce(async () => { await gate; return resp(500, []); })
521
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']));
522
+ const first = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF();
523
+ const firstResult = first.catch(e => e);
524
+ const second = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF();
525
+ await flush();
526
+ release();
527
+ expect(await firstResult).toBeInstanceOf(Error);
528
+ expect((await second).sessionCookie).toBe('abc.tomcat1');
529
+ });
530
+ it('parallel events rejected with 403 create only one replacement session', async () => {
531
+ const u = uniqueUser();
532
+ fetchMock().mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']));
533
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF(); // prime the cache with the old session
534
+ fetchMock().mockReset();
535
+ fetchMock().mockImplementation(async (_url, opts) => {
536
+ const cookie = opts.headers.Cookie;
537
+ if (opts.method === 'POST')
538
+ return cookie === 'JSESSIONID=new.tomcat1' ? resp(200, [], {}) : resp(403, [], {});
539
+ if (!cookie)
540
+ return resp(200, ['JSESSIONID=new.tomcat1']);
541
+ return resp(200, []);
542
+ });
543
+ await Promise.all([1, 2, 3].map(() => new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload)));
544
+ expect(fetchMock().mock.calls.filter(isColdGet)).toHaveLength(1);
545
+ });
546
+ });
324
547
  });
325
548
  describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
326
549
  const payload = { id: '123' };
@@ -356,7 +579,7 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
356
579
  expect(callHeaders(1).Cookie).toBeUndefined();
357
580
  });
358
581
  });
359
- describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)', () => {
582
+ describe('FlexPLMConnect.getRequest session reuse', () => {
360
583
  it('sends the cached cookie on image/GET requests', async () => {
361
584
  const u = uniqueUser();
362
585
  fetchMock()
@@ -381,5 +604,20 @@ describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)'
381
604
  expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
382
605
  expect(callHeaders(3).Cookie).toBeUndefined();
383
606
  });
607
+ it('does not re-store the rejected cookie when the retry issues no Set-Cookie', async () => {
608
+ const u = uniqueUser();
609
+ fetchMock()
610
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=stale.tomcat1']))
611
+ .mockResolvedValueOnce(resp(200, [], {}))
612
+ .mockResolvedValueOnce(resp(401, [])) // getRequest with the cached cookie
613
+ .mockResolvedValueOnce(resp(200, [], { items: [] })) // retry succeeds, no Set-Cookie
614
+ .mockResolvedValueOnce(resp(200, [], { items: [] })); // next getRequest
615
+ const connect = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u));
616
+ await connect.sendToFlexPLM({ id: '1' });
617
+ await connect.getRequest();
618
+ await connect.getRequest();
619
+ expect(callHeaders(3).Cookie).toBeUndefined();
620
+ expect(callHeaders(4).Cookie).toBeUndefined(); // stale cookie must not be sent again
621
+ });
384
622
  });
385
623
  // Added for Session reuse - ET-390 - Ends
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/flexplm",
3
- "version": "1.7.7-alpha.d6a63ad",
3
+ "version": "1.7.7-alpha.e66ae52",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",