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

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.
@@ -1,3 +1,15 @@
1
+ export interface SessionReuseConfig {
2
+ enabled?: boolean | string;
3
+ ttlSeconds?: number | string;
4
+ cookieName?: string;
5
+ retryStatuses?: number | number[] | string;
6
+ }
7
+ export interface FlexPLMConnectConfig {
8
+ staticHeaders?: {
9
+ [key: string]: string;
10
+ };
11
+ sessionReuse?: SessionReuseConfig;
12
+ }
1
13
  export interface FCConfig {
2
14
  apiKey?: string;
3
15
  orgSlug?: string;
@@ -5,7 +17,7 @@ export interface FCConfig {
5
17
  configFile?: string;
6
18
  eventObjectClassParam?: string;
7
19
  eventFlexTypePathParam?: string;
8
- flexplmConnect?: any;
20
+ flexplmConnect?: FlexPLMConnectConfig;
9
21
  taskId?: string;
10
22
  apiHost: string;
11
23
  userName(): string;
@@ -1,24 +1,16 @@
1
1
  import { FCConfig, FlexPLMResponseData, PayloadType } from '../interfaces/interfaces';
2
2
  export declare class FlexPLMConnect {
3
3
  private config;
4
+ private sessions;
4
5
  private vibeEventEndpoint;
5
6
  private staticHeaders;
6
7
  payloadSendAsArray: boolean;
7
8
  constructor(_config: FCConfig, endPoint?: any, payloadAsArray?: any);
8
- private get sessionOptions();
9
- private getSessionKey;
10
- private getCachedSession;
11
- private storeSession;
12
- private clearSession;
13
- /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
14
- private extractSessionCookie;
15
- private cookieHeader;
9
+ /** Releases the connection of a rejected response before it is replaced by a retry. */
10
+ private discardBody;
16
11
  private getRequestOptions;
17
- getCSRF(getNewSession?: boolean): Promise<{
18
- nonce_key: any;
19
- nonce: any;
20
- sessionCookie: string;
21
- }>;
12
+ getCSRF(getNewSession?: boolean, rejectedCookie?: string): any;
13
+ private fetchCSRF;
22
14
  sendRequest(csrf: any, payload: any): Promise<Response>;
23
15
  protected processRequest(payload: any): Promise<FlexPLMResponseData>;
24
16
  sendToFlexPLM(payload: PayloadType): Promise<FlexPLMResponseData>;
@@ -2,15 +2,23 @@
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.
6
- const sessionCache = new Map();
7
- // Added for Session reuse - ET-390 - Ends
5
+ const flexplm_session_manager_1 = require("./flexplm-session-manager");
6
+ // Cookie values carry the session id: mask them in logs whatever the header-name casing.
7
+ const maskCookieHeaders = (headers) => {
8
+ if (!headers)
9
+ return;
10
+ for (const key of Object.keys(headers)) {
11
+ if (key.toLowerCase() === 'cookie' && headers[key])
12
+ headers[key] = '***masked***';
13
+ }
14
+ };
8
15
  class FlexPLMConnect {
9
16
  constructor(_config, endPoint = undefined, payloadAsArray = undefined) {
10
17
  this.vibeEventEndpoint = '';
11
18
  this.staticHeaders = undefined;
12
19
  this.payloadSendAsArray = true;
13
20
  this.config = _config;
21
+ this.sessions = new flexplm_session_manager_1.FlexPLMSessionManager(_config);
14
22
  this.vibeEventEndpoint = (endPoint)
15
23
  ? endPoint
16
24
  : this.config.vibeEventEndpoint;
@@ -21,71 +29,17 @@ class FlexPLMConnect {
21
29
  this.staticHeaders = this.config?.flexplmConnect?.staticHeaders;
22
30
  }
23
31
  }
24
- /////////////////////////////////////////////////////////////////////////////
25
- ///////// ET-390 - Session reuse: start
26
- /////////////////////////////////////////////////////////////////////////////
27
- get sessionOptions() {
28
- const sessReuseBool = this.config?.flexplmConnect?.sessionReuse;
29
- 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]),
34
- };
35
- }
36
- getSessionKey() {
37
- return [this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment].join('|');
38
- }
39
- getCachedSession() {
40
- if (!this.sessionOptions.enabled)
41
- return undefined;
42
- const key = this.getSessionKey();
43
- const cachedSessValue = sessionCache.get(key);
44
- if (!cachedSessValue)
45
- return undefined;
46
- if (cachedSessValue.expiresAt <= Date.now()) {
47
- sessionCache.delete(key);
48
- return undefined;
49
- }
50
- return cachedSessValue.cookie;
51
- }
52
- storeSession(cookie) {
53
- // sliding expiry: Windchill's session timeout is also sliding
54
- sessionCache.set(this.getSessionKey(), { cookie, expiresAt: Date.now() + this.sessionOptions.ttlMs });
55
- }
56
- clearSession() {
57
- sessionCache.delete(this.getSessionKey());
58
- }
59
- /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
60
- extractSessionCookie(response) {
61
- const respHeader = response?.headers;
62
- if (!respHeader)
63
- return undefined;
64
- let raw = [];
65
- if (typeof respHeader.getSetCookie === 'function') {
66
- raw = respHeader.getSetCookie();
67
- }
68
- else if (typeof respHeader.get === 'function' && respHeader.get('set-cookie')) {
69
- raw = [respHeader.get('set-cookie')];
32
+ // ET-390 - Session reuse
33
+ /** Releases the connection of a rejected response before it is replaced by a retry. */
34
+ async discardBody(response) {
35
+ try {
36
+ if (typeof response?.body?.cancel === 'function')
37
+ await response.body.cancel();
70
38
  }
71
- const name = this.sessionOptions.cookieName;
72
- const regexFilter = new RegExp('(?:^|[,\\s])' + name + '=([^;,\\s]+)', 'g');
73
- let jSession;
74
- for (const line of raw) {
75
- let cookieNameMatcher = regexFilter.exec(line);
76
- while (cookieNameMatcher !== null) {
77
- jSession = cookieNameMatcher[1];
78
- cookieNameMatcher = regexFilter.exec(line);
79
- }
39
+ catch (e) {
40
+ // best effort: nothing to do if the body is already consumed or locked
80
41
  }
81
- return jSession;
82
42
  }
83
- cookieHeader(sessionCookie) {
84
- return sessionCookie ? `${this.sessionOptions.cookieName}=${sessionCookie}` : undefined;
85
- }
86
- /////////////////////////////////////////////////////////////////////////////
87
- ///////// ET-390 - Session reuse: end
88
- /////////////////////////////////////////////////////////////////////////////
89
43
  /////////////////////////////////////////////////////////////////////////////
90
44
  ///////// Custom getRequestOptions: start
91
45
  /////////////////////////////////////////////////////////////////////////////
@@ -103,17 +57,21 @@ class FlexPLMConnect {
103
57
  Object.assign(csrfOptions.headers, this.staticHeaders);
104
58
  }
105
59
  // Added for Session reuse - ET-390 - Starts
106
- const cookie = this.cookieHeader(sessionCookie);
60
+ // Merge with a configured static Cookie header (any header-name casing) instead of replacing it.
61
+ const headers = csrfOptions.headers;
62
+ const staticCookieKeys = Object.keys(headers).filter(key => key.toLowerCase() === 'cookie');
63
+ const staticCookie = staticCookieKeys.map(key => headers[key]).join('; ');
64
+ const cookie = this.sessions.buildCookieHeader(staticCookie, sessionCookie);
65
+ staticCookieKeys.forEach(key => delete headers[key]);
107
66
  if (cookie) {
108
- csrfOptions.headers['Cookie'] = cookie;
67
+ headers['Cookie'] = cookie;
109
68
  }
110
69
  // Added for Session reuse - ET-390 - Ends
111
70
  if (app_framework_1.Logger.isInfoOn()) {
112
71
  const logOptions = JSON.parse(JSON.stringify(csrfOptions));
113
72
  logOptions['headers']['Authorization'] = logOptions?.headers?.Authorization.substring(0, 9);
114
73
  // Added for Session reuse - ET-390 - Starts
115
- if (logOptions.headers['Cookie'])
116
- logOptions.headers['Cookie'] = '***masked***';
74
+ maskCookieHeaders(logOptions.headers);
117
75
  // Added for Session reuse - ET-390 - Ends
118
76
  console.info('csrfOptions: ' + JSON.stringify(logOptions));
119
77
  }
@@ -124,26 +82,48 @@ class FlexPLMConnect {
124
82
  /////////////////////////////////////////////////////////////////////////////
125
83
  // ET-390 - adding a new parameter getNewSession to the getCSRF function
126
84
  // Whenever a getCSRF is called without getNewSession, the default value of false will be used
127
- async getCSRF(getNewSession = false) {
85
+ // rejectedCookie: the cookie that was rejected by the caller, so a refresh already done by a concurrent event is not thrown away
86
+ async getCSRF(getNewSession = false, rejectedCookie) {
87
+ // Added for Session reuse - ET-390 - Starts
88
+ if (!this.sessions.options.enabled)
89
+ return this.fetchCSRF(undefined);
90
+ for (let wait = this.sessions.waitForInFlightAcquisition(); wait; wait = this.sessions.waitForInFlightAcquisition())
91
+ await wait;
92
+ if (getNewSession) {
93
+ const current = this.sessions.getCachedSession();
94
+ if (rejectedCookie === undefined || !current || current === rejectedCookie)
95
+ this.sessions.clearSession();
96
+ }
97
+ const cached = this.sessions.getCachedSession();
98
+ if (cached) {
99
+ try {
100
+ return await this.fetchCSRF(cached);
101
+ }
102
+ catch (e) {
103
+ if (!(e instanceof flexplm_session_manager_1.SessionRejectedError))
104
+ throw e;
105
+ // if the cached cookie is rejected (401/403), drop it and acquire a new session (serialized)
106
+ if (this.sessions.getCachedSession() === cached)
107
+ this.sessions.clearSession();
108
+ return this.getCSRF();
109
+ }
110
+ }
111
+ return this.sessions.runColdAcquisition(() => this.fetchCSRF(undefined));
112
+ // Added for Session reuse - ET-390 - Ends
113
+ }
114
+ async fetchCSRF(sentCookie) {
128
115
  const urlContext = this.config.urlContext;
129
116
  const csrfEndpoint = this.config.csrfEndpoint;
130
117
  const csrfURL = this.config.apiHost + urlContext + csrfEndpoint;
131
118
  console.info('csrfURL: ' + csrfURL);
132
- // Added for Session reuse - ET-390 - Starts
133
- if (getNewSession)
134
- this.clearSession();
135
- let sentCookie = this.getCachedSession();
136
119
  // getRequestOptions will be called with additional sentCookie parameter
137
120
  const csrfOptions = this.getRequestOptions('GET', sentCookie);
138
- // Added for Session reuse - ET-390 - Ends
139
- let response = await fetch(csrfURL, csrfOptions);
121
+ const response = await fetch(csrfURL, csrfOptions);
140
122
  // Added for Session reuse - ET-390 - Starts
141
- // if the response status is 401 or 403, then get a new session id
142
- if (sentCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
123
+ if (sentCookie && this.sessions.options.retryStatuses.includes(response.status)) {
143
124
  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'));
125
+ await this.discardBody(response);
126
+ throw new flexplm_session_manager_1.SessionRejectedError();
147
127
  }
148
128
  // Added for Session reuse - ET-390 - Ends
149
129
  if (response.status >= 300) {
@@ -157,22 +137,21 @@ class FlexPLMConnect {
157
137
  // Added for Session reuse - ET-390 - Starts
158
138
  // If the cookie we sent was invalid, Windchill answers with a fresh Set-Cookie --> replace the cache.
159
139
  let sessionCookie = sentCookie;
160
- if (this.sessionOptions.enabled) {
161
- sessionCookie = this.extractSessionCookie(response) ?? sentCookie;
140
+ if (this.sessions.options.enabled) {
141
+ sessionCookie = this.sessions.extractSessionCookie(response) ?? sentCookie;
162
142
  if (sessionCookie)
163
- this.storeSession(sessionCookie);
143
+ this.sessions.storeSession(sessionCookie, sentCookie);
164
144
  }
165
145
  // Added for Session reuse - ET-390 - Ends
166
146
  try {
167
147
  const body = await response.json();
168
148
  const nonce_key = body.items[0].attributes.nonce_key;
169
149
  const nonce = body.items[0].attributes.nonce;
170
- console.info('nonce_key: ' + nonce_key);
171
- //ET-390 - adding sessionCookie to the return
150
+ // ET-390 - adding sessionCookie to the return
172
151
  return {
173
152
  nonce_key,
174
153
  nonce,
175
- sessionCookie
154
+ sessionCookie,
176
155
  };
177
156
  }
178
157
  catch (e) {
@@ -197,8 +176,7 @@ class FlexPLMConnect {
197
176
  const logOptions = JSON.parse(JSON.stringify(xferOptions));
198
177
  if (logOptions?.headers?.Authorization)
199
178
  logOptions.headers.Authorization = logOptions?.headers?.Authorization.substring(0, 9);
200
- if (logOptions?.headers?.Cookie)
201
- logOptions.headers.Cookie = '***masked***';
179
+ maskCookieHeaders(logOptions?.headers);
202
180
  if (csrf?.nonce_key)
203
181
  logOptions.headers[csrf.nonce_key] = csrf.nonce ? `***masked(length:${csrf.nonce.length})***` : '***masked(empty)***';
204
182
  console.info('csrfOptions: ' + JSON.stringify(logOptions));
@@ -225,9 +203,10 @@ class FlexPLMConnect {
225
203
  /// Added for Session reuse - ET-390 - Starts
226
204
  // const response = await this.sendRequest(csrf, payload);
227
205
  let response = await this.sendRequest(csrf, payload);
228
- if (csrf.sessionCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
206
+ if (csrf.sessionCookie && this.sessions.options.retryStatuses.includes(response.status)) {
229
207
  console.warn('FlexPLM rejected session/nonce (status ' + response.status + '). Getting a new session and retrying once.');
230
- csrf = await this.getCSRF(true);
208
+ await this.discardBody(response);
209
+ csrf = await this.getCSRF(true, csrf.sessionCookie);
231
210
  response = await this.sendRequest(csrf, payload);
232
211
  }
233
212
  // Added for Session reuse - ET-390 - Ends
@@ -284,17 +263,32 @@ class FlexPLMConnect {
284
263
  const path = urlPath || ('/servlet/rest' + this.vibeEventEndpoint);
285
264
  const vibeEventsURL = this.config.apiHost + urlContext + path;
286
265
  // 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);
266
+ const attempt = async () => {
267
+ if (!this.sessions.options.enabled) {
268
+ return { response: await fetch(vibeEventsURL, this.getRequestOptions('GET')), sent: undefined };
269
+ }
270
+ for (let wait = this.sessions.waitForInFlightAcquisition(); wait; wait = this.sessions.waitForInFlightAcquisition())
271
+ await wait;
272
+ const sent = this.sessions.getCachedSession();
273
+ const call = async () => {
274
+ const res = await fetch(vibeEventsURL, this.getRequestOptions('GET', sent));
275
+ // A rejected cookie must not be stored again; only keep a cookie that was accepted or newly issued.
276
+ if (!(sent && this.sessions.options.retryStatuses.includes(res.status))) {
277
+ const cookie = this.sessions.extractSessionCookie(res) ?? sent;
278
+ if (cookie)
279
+ this.sessions.storeSession(cookie, sent);
280
+ }
281
+ return { response: res, sent };
282
+ };
283
+ return sent ? call() : this.sessions.runColdAcquisition(call);
284
+ };
285
+ const first = await attempt();
286
+ let response = first.response;
287
+ if (first.sent && this.sessions.options.retryStatuses.includes(response.status)) {
288
+ await this.discardBody(response);
289
+ if (this.sessions.getCachedSession() === first.sent)
290
+ this.sessions.clearSession();
291
+ response = (await attempt()).response;
298
292
  }
299
293
  // Added for Session reuse - ET-390 - Ends
300
294
  if (response.status >= 300) {
@@ -39,6 +39,7 @@ const resp = (status, setCookie = [], json = csrfBody) => ({
39
39
  });
40
40
  let userCounter = 0;
41
41
  const uniqueUser = () => 'sess-user-' + (++userCounter);
42
+ // Session options are nested: { flexplmConnect: { sessionReuse: { enabled, ... } } }
42
43
  const sessionConfig = (user, sessionReuse = { enabled: true }) => createConfig({ userName: () => user, flexplmConnect: { sessionReuse } });
43
44
  const fetchMock = () => global.fetch;
44
45
  const callHeaders = (i) => fetchMock().mock.calls[i][1].headers;
@@ -140,7 +141,7 @@ describe('FlexPLMConnect.sendRequest', () => {
140
141
  // Added for Session reuse - ET-390 - Starts
141
142
  describe('FlexPLMConnect session reuse', () => {
142
143
  const payload = { id: '123' };
143
- it('captures JSESSIONID from the csrf GET and sends it on the POST', async () => {
144
+ it('should capture JSESSIONID from the CSRF GET and send it on the POST', async () => {
144
145
  const u = uniqueUser();
145
146
  fetchMock()
146
147
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/; HttpOnly']))
@@ -151,7 +152,7 @@ describe('FlexPLMConnect session reuse', () => {
151
152
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
152
153
  expect(callHeaders(1).CSRF_NONCE).toBe('n1');
153
154
  });
154
- it('reuses the cached session on the next event (new FlexPLMConnect instance)', async () => {
155
+ it('should reuse the cached session on the next event (new FlexPLMConnect instance)', async () => {
155
156
  const u = uniqueUser();
156
157
  fetchMock()
157
158
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/'])) // event 1 GET
@@ -163,7 +164,7 @@ describe('FlexPLMConnect session reuse', () => {
163
164
  expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1'); // GET reuses it
164
165
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=abc.tomcat1'); // POST reuses it
165
166
  });
166
- it('replaces the cached session when Windchill answers the GET with a new Set-Cookie', async () => {
167
+ it('should replace the cached session when Windchill answers the GET with a new Set-Cookie', async () => {
167
168
  const u = uniqueUser();
168
169
  fetchMock()
169
170
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1; Path=/']))
@@ -180,7 +181,7 @@ describe('FlexPLMConnect session reuse', () => {
180
181
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=new.tomcat1'); // POST uses the replacement
181
182
  expect(callHeaders(4).Cookie).toBe('JSESSIONID=new.tomcat1'); // event 3 GET sends the replacement
182
183
  });
183
- it('retries once with a new session when the POST is rejected with 403', async () => {
184
+ it('should retry once with a new session when the POST is rejected with 403', async () => {
184
185
  const u = uniqueUser();
185
186
  fetchMock()
186
187
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1; Path=/'])) // GET
@@ -194,7 +195,7 @@ describe('FlexPLMConnect session reuse', () => {
194
195
  expect(result.status).toBe(200);
195
196
  expect(result.data).toEqual({ ok: true });
196
197
  });
197
- it('also retries on 401', async () => {
198
+ it('should also retry on 401', async () => {
198
199
  const u = uniqueUser();
199
200
  fetchMock()
200
201
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
@@ -204,7 +205,7 @@ describe('FlexPLMConnect session reuse', () => {
204
205
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
205
206
  expect(fetchMock()).toHaveBeenCalledTimes(4);
206
207
  });
207
- it('does not loop: a second 403 after the retry is surfaced as an error', async () => {
208
+ it('should not loop: a second 403 after the retry is surfaced as an error', async () => {
208
209
  const u = uniqueUser();
209
210
  fetchMock()
210
211
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
@@ -215,7 +216,7 @@ describe('FlexPLMConnect session reuse', () => {
215
216
  .rejects.toThrow('Error sending data to FlexPLM:status: 403');
216
217
  expect(fetchMock()).toHaveBeenCalledTimes(4);
217
218
  });
218
- it('does not retry on non-auth errors such as 500 (event may already be processed)', async () => {
219
+ it('should not retry on non-auth errors such as 500 (event may already be processed)', async () => {
219
220
  const u = uniqueUser();
220
221
  fetchMock()
221
222
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -224,7 +225,7 @@ describe('FlexPLMConnect session reuse', () => {
224
225
  .rejects.toThrow('Error sending data to FlexPLM:status: 500');
225
226
  expect(fetchMock()).toHaveBeenCalledTimes(2);
226
227
  });
227
- it('retries the csrf GET without a cookie when the cached session is rejected there', async () => {
228
+ it('should retry the csrf GET without a cookie when the cached session is rejected there', async () => {
228
229
  const u = uniqueUser();
229
230
  fetchMock()
230
231
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1'])) // event 1 GET
@@ -238,7 +239,7 @@ describe('FlexPLMConnect session reuse', () => {
238
239
  expect(callHeaders(3).Cookie).toBeUndefined();
239
240
  expect(callHeaders(4).Cookie).toBe('JSESSIONID=new.tomcat1');
240
241
  });
241
- it('expires the cached session after ttlSeconds', async () => {
242
+ it('should expire the cached session after ttlSeconds', async () => {
242
243
  const u = uniqueUser();
243
244
  const realNow = Date.now();
244
245
  const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
@@ -255,7 +256,7 @@ describe('FlexPLMConnect session reuse', () => {
255
256
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=def.tomcat1');
256
257
  nowSpy.mockRestore();
257
258
  });
258
- it('uses the last JSESSIONID when several Set-Cookie lines are returned', async () => {
259
+ it('should use the last JSESSIONID when several Set-Cookie lines are returned', async () => {
259
260
  const u = uniqueUser();
260
261
  fetchMock()
261
262
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=first.tomcat1; Path=/', 'OTHER=x; Path=/', 'JSESSIONID=second.tomcat1; Path=/']))
@@ -263,7 +264,7 @@ describe('FlexPLMConnect session reuse', () => {
263
264
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
264
265
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=second.tomcat1');
265
266
  });
266
- it('falls back to headers.get("set-cookie") when getSetCookie is not available', async () => {
267
+ it('should fall back to headers.get("set-cookie") when getSetCookie is not available', async () => {
267
268
  const u = uniqueUser();
268
269
  const legacy = {
269
270
  status: 200,
@@ -275,7 +276,7 @@ describe('FlexPLMConnect session reuse', () => {
275
276
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
276
277
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=legacy.tomcat1');
277
278
  });
278
- it('honours a custom cookieName', async () => {
279
+ it('should honour a custom cookieName', async () => {
279
280
  const u = uniqueUser();
280
281
  fetchMock()
281
282
  .mockResolvedValueOnce(resp(200, ['MYSESSION=zzz; Path=/', 'JSESSIONID=ignored; Path=/']))
@@ -283,7 +284,7 @@ describe('FlexPLMConnect session reuse', () => {
283
284
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'MYSESSION' })).sendToFlexPLM(payload);
284
285
  expect(callHeaders(1).Cookie).toBe('MYSESSION=zzz');
285
286
  });
286
- it('accepts enabled as the string "true"', async () => {
287
+ it('should accept enabled as the string "true"', async () => {
287
288
  const u = uniqueUser();
288
289
  fetchMock()
289
290
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -291,7 +292,39 @@ describe('FlexPLMConnect session reuse', () => {
291
292
  await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: 'true' })).sendToFlexPLM(payload);
292
293
  expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
293
294
  });
294
- it('keeps separate sessions per user', async () => {
295
+ it('should evict expired sessions of other users when a new session is stored', async () => {
296
+ const userA = uniqueUser();
297
+ const userB = uniqueUser();
298
+ const realNow = Date.now();
299
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
300
+ fetchMock()
301
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=a.tomcat1'])) // A GET
302
+ .mockResolvedValueOnce(resp(200, [], {})) // A POST
303
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=b.tomcat1'])) // B GET
304
+ .mockResolvedValueOnce(resp(200, [], {})) // B POST
305
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=a2.tomcat1'])) // A GET again
306
+ .mockResolvedValueOnce(resp(200, [], {}));
307
+ const cfg = (u) => sessionConfig(u, { enabled: true, ttlSeconds: 1 });
308
+ await new flexplm_connect_1.FlexPLMConnect(cfg(userA)).sendToFlexPLM(payload);
309
+ nowSpy.mockReturnValue(realNow + 2000); // A has expired
310
+ await new flexplm_connect_1.FlexPLMConnect(cfg(userB)).sendToFlexPLM(payload); // storing B sweeps A
311
+ nowSpy.mockReturnValue(realNow); // rewind: only a swept A has no entry left
312
+ await new flexplm_connect_1.FlexPLMConnect(cfg(userA)).sendToFlexPLM(payload);
313
+ expect(callHeaders(4).Cookie).toBeUndefined();
314
+ nowSpy.mockRestore();
315
+ });
316
+ it('should not hold more than the maximum number of cached sessions', async () => {
317
+ const first = uniqueUser();
318
+ fetchMock().mockImplementation(async () => resp(200, ['JSESSIONID=s.tomcat1']));
319
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(first)).sendToFlexPLM(payload);
320
+ for (let i = 0; i < 100; i++) {
321
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(uniqueUser())).sendToFlexPLM(payload);
322
+ }
323
+ fetchMock().mockClear();
324
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(first)).sendToFlexPLM(payload);
325
+ expect(callHeaders(0).Cookie).toBeUndefined(); // oldest entry was evicted
326
+ });
327
+ it('should keep separate sessions per user', async () => {
295
328
  const a = uniqueUser();
296
329
  const b = uniqueUser();
297
330
  fetchMock()
@@ -304,7 +337,7 @@ describe('FlexPLMConnect session reuse', () => {
304
337
  expect(callHeaders(2).Cookie).toBeUndefined(); // user b must not see user a's cookie
305
338
  expect(callHeaders(3).Cookie).toBe('JSESSIONID=bbb.tomcat1');
306
339
  });
307
- it('never logs the cookie value', async () => {
340
+ it('should never log the cookie value', async () => {
308
341
  const isInfoOn = jest.spyOn(app_framework_1.Logger, 'isInfoOn').mockReturnValue(true);
309
342
  const info = jest.spyOn(console, 'info').mockImplementation();
310
343
  const u = uniqueUser();
@@ -321,10 +354,317 @@ describe('FlexPLMConnect session reuse', () => {
321
354
  isInfoOn.mockRestore();
322
355
  info.mockRestore();
323
356
  });
357
+ describe('static Cookie header', () => {
358
+ const withStatic = (user, staticHeaders, sessionReuse = { enabled: true }) => createConfig({ userName: () => user, flexplmConnect: { sessionReuse, staticHeaders } });
359
+ it('should keep other static cookies and replace only the managed one once a session is cached', async () => {
360
+ const u = uniqueUser();
361
+ fetchMock()
362
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1; Path=/']))
363
+ .mockResolvedValueOnce(resp(200, [], {}));
364
+ await new flexplm_connect_1.FlexPLMConnect(withStatic(u, { Cookie: 'lb=abc; JSESSIONID=stale; theme=dark' })).sendToFlexPLM(payload);
365
+ expect(callHeaders(0).Cookie).toBe('lb=abc; theme=dark'); // cold: stale managed cookie dropped
366
+ expect(callHeaders(1).Cookie).toBe('lb=abc; theme=dark; JSESSIONID=new.tomcat1');
367
+ });
368
+ it('should send a single Cookie header when the static header name is lowercase', async () => {
369
+ const u = uniqueUser();
370
+ fetchMock()
371
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/']))
372
+ .mockResolvedValueOnce(resp(200, [], {}));
373
+ await new flexplm_connect_1.FlexPLMConnect(withStatic(u, { cookie: 'lb=abc' })).sendToFlexPLM(payload);
374
+ const postHeaders = callHeaders(1);
375
+ expect(Object.keys(postHeaders).filter(k => k.toLowerCase() === 'cookie')).toEqual(['Cookie']);
376
+ expect(postHeaders.Cookie).toBe('lb=abc; JSESSIONID=abc.tomcat1');
377
+ });
378
+ it('should send the static Cookie unchanged when session reuse is disabled', async () => {
379
+ const u = uniqueUser();
380
+ fetchMock()
381
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/']))
382
+ .mockResolvedValueOnce(resp(200, [], {}));
383
+ await new flexplm_connect_1.FlexPLMConnect(withStatic(u, { Cookie: 'lb=abc; JSESSIONID=static' }, { enabled: false })).sendToFlexPLM(payload);
384
+ expect(callHeaders(0).Cookie).toBe('lb=abc; JSESSIONID=static');
385
+ expect(callHeaders(1).Cookie).toBe('lb=abc; JSESSIONID=static');
386
+ });
387
+ it('should not resend a rejected static JSESSIONID on the fresh retry', async () => {
388
+ const u = uniqueUser();
389
+ fetchMock()
390
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1; Path=/']))
391
+ .mockResolvedValueOnce(resp(403, [], {})) // POST rejected
392
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=fresh.tomcat1; Path=/'])) // fresh GET
393
+ .mockResolvedValueOnce(resp(200, [], {}));
394
+ await new flexplm_connect_1.FlexPLMConnect(withStatic(u, { Cookie: 'lb=abc; JSESSIONID=static' })).sendToFlexPLM(payload);
395
+ expect(callHeaders(2).Cookie).toBe('lb=abc'); // fresh GET carries no JSESSIONID
396
+ expect(callHeaders(3).Cookie).toBe('lb=abc; JSESSIONID=fresh.tomcat1');
397
+ });
398
+ it('should mask a lowercase static cookie header in logs', async () => {
399
+ const isInfoOn = jest.spyOn(app_framework_1.Logger, 'isInfoOn').mockReturnValue(true);
400
+ const info = jest.spyOn(console, 'info').mockImplementation();
401
+ fetchMock()
402
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
403
+ .mockResolvedValueOnce(resp(200, [], {}));
404
+ await new flexplm_connect_1.FlexPLMConnect(withStatic(uniqueUser(), { cookie: 'lb=topsecret' }, { enabled: false })).sendToFlexPLM(payload);
405
+ const logged = info.mock.calls.map(c => String(c[0] ?? '')).join('\n');
406
+ expect(logged).not.toContain('topsecret');
407
+ isInfoOn.mockRestore();
408
+ info.mockRestore();
409
+ });
410
+ });
411
+ describe('option normalisation', () => {
412
+ const rejectedOnceThenOk = (u, opts) => {
413
+ fetchMock()
414
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
415
+ .mockResolvedValueOnce(resp(403, [], {}))
416
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
417
+ .mockResolvedValueOnce(resp(200, [], {}));
418
+ return new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ...opts })).sendToFlexPLM(payload);
419
+ };
420
+ it.each([
421
+ ['a scalar number', 403],
422
+ ['a comma separated string', '401, 403'],
423
+ ['garbage (falls back to 401/403)', 'abc'],
424
+ ['an empty array (falls back to 401/403)', []],
425
+ ])('should not throw and still retry on 403 when retryStatuses is %s', async (_label, retryStatuses) => {
426
+ await rejectedOnceThenOk(uniqueUser(), { retryStatuses });
427
+ expect(fetchMock()).toHaveBeenCalledTimes(4);
428
+ });
429
+ it.each([['abc'], [0], [-5]])('should fall back to the 900s default when ttlSeconds is invalid (%p)', async (ttlSeconds) => {
430
+ const u = uniqueUser();
431
+ const realNow = Date.now();
432
+ const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
433
+ fetchMock()
434
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
435
+ .mockResolvedValueOnce(resp(200, [], {}))
436
+ .mockResolvedValueOnce(resp(200, []))
437
+ .mockResolvedValueOnce(resp(200, [], {}));
438
+ const cfg = sessionConfig(u, { enabled: true, ttlSeconds });
439
+ await new flexplm_connect_1.FlexPLMConnect(cfg).sendToFlexPLM(payload);
440
+ nowSpy.mockReturnValue(realNow + 2000);
441
+ await new flexplm_connect_1.FlexPLMConnect(cfg).sendToFlexPLM(payload);
442
+ expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
443
+ nowSpy.mockRestore();
444
+ });
445
+ it('should fall back to JSESSIONID when cookieName is blank', async () => {
446
+ const u = uniqueUser();
447
+ fetchMock()
448
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
449
+ .mockResolvedValueOnce(resp(200, [], {}));
450
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: ' ' })).sendToFlexPLM(payload);
451
+ expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
452
+ });
453
+ });
454
+ describe('retryStatuses restricted to client-error statuses', () => {
455
+ const postFailsWith = async (status, retryStatuses) => {
456
+ const u = uniqueUser();
457
+ fetchMock()
458
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
459
+ .mockResolvedValueOnce(resp(status, [], {}));
460
+ await expect(new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, retryStatuses })).sendToFlexPLM({ id: '1' }))
461
+ .rejects.toThrow('Error sending data to FlexPLM:status: ' + status);
462
+ expect(fetchMock()).toHaveBeenCalledTimes(2); // no retry
463
+ };
464
+ it.each([[500], ['500'], [[200, 500]]])('should ignore non-4xx retryStatuses (%p) and not retry a 500', async (retryStatuses) => {
465
+ await postFailsWith(500, retryStatuses);
466
+ });
467
+ it('should keep 4xx entries and drop others from a mixed retryStatuses list', async () => {
468
+ await postFailsWith(500, [401, 500]);
469
+ });
470
+ it('should not resend a successful event when retryStatuses is configured as 200', async () => {
471
+ const u = uniqueUser();
472
+ fetchMock()
473
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
474
+ .mockResolvedValueOnce(resp(200, [], {}));
475
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, retryStatuses: 200 })).sendToFlexPLM({ id: '1' });
476
+ expect(fetchMock()).toHaveBeenCalledTimes(2);
477
+ });
478
+ });
479
+ describe('session cache key includes session options', () => {
480
+ it('should not share a cached cookie between different cookieNames for the same user', async () => {
481
+ const u = uniqueUser();
482
+ fetchMock()
483
+ .mockResolvedValueOnce(resp(200, ['AAA=a1; Path=/']))
484
+ .mockResolvedValueOnce(resp(200, [], {}))
485
+ .mockResolvedValueOnce(resp(200, ['BBB=b1; Path=/']))
486
+ .mockResolvedValueOnce(resp(200, [], {}));
487
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'AAA' })).sendToFlexPLM({ id: '1' });
488
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'BBB' })).sendToFlexPLM({ id: '1' });
489
+ expect(callHeaders(2).Cookie).toBeUndefined(); // BBB config must not pick up the AAA session
490
+ expect(callHeaders(3).Cookie).toBe('BBB=b1');
491
+ });
492
+ it('should not share a cached session between different ttlSeconds for the same user', async () => {
493
+ const u = uniqueUser();
494
+ fetchMock()
495
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=short.tomcat1']))
496
+ .mockResolvedValueOnce(resp(200, [], {}))
497
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=long.tomcat1']))
498
+ .mockResolvedValueOnce(resp(200, [], {}));
499
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ttlSeconds: 1 })).sendToFlexPLM({ id: '1' });
500
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, ttlSeconds: 900 })).sendToFlexPLM({ id: '1' });
501
+ expect(callHeaders(2).Cookie).toBeUndefined();
502
+ expect(callHeaders(3).Cookie).toBe('JSESSIONID=long.tomcat1');
503
+ });
504
+ });
505
+ describe('rejected response disposal', () => {
506
+ const withBody = (r) => Object.assign(r, { body: { cancel: jest.fn().mockResolvedValue(undefined) } });
507
+ it('should cancel the body of a POST rejected with 403 before retrying', async () => {
508
+ const u = uniqueUser();
509
+ const rejected = withBody(resp(403, [], {}));
510
+ fetchMock()
511
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
512
+ .mockResolvedValueOnce(rejected)
513
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
514
+ .mockResolvedValueOnce(resp(200, [], {}));
515
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
516
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
517
+ });
518
+ it('should cancel the body of a CSRF GET rejected with the cached cookie before retrying', async () => {
519
+ const u = uniqueUser();
520
+ const rejected = withBody(resp(401, []));
521
+ fetchMock()
522
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
523
+ .mockResolvedValueOnce(resp(200, [], {}))
524
+ .mockResolvedValueOnce(rejected)
525
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
526
+ .mockResolvedValueOnce(resp(200, [], {}));
527
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
528
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
529
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
530
+ });
531
+ it('should cancel the body of a getRequest rejected with the cached cookie before retrying', async () => {
532
+ const u = uniqueUser();
533
+ const rejected = withBody(resp(401, []));
534
+ fetchMock()
535
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
536
+ .mockResolvedValueOnce(resp(200, [], {}))
537
+ .mockResolvedValueOnce(rejected)
538
+ .mockResolvedValueOnce(resp(200, [], { items: [] }));
539
+ const connect = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u));
540
+ await connect.sendToFlexPLM({ id: '1' });
541
+ await connect.getRequest();
542
+ expect(rejected.body.cancel).toHaveBeenCalledTimes(1);
543
+ });
544
+ it('should still retry when cancelling the body throws', async () => {
545
+ const u = uniqueUser();
546
+ const rejected = resp(403, [], {});
547
+ rejected.body = { cancel: jest.fn().mockRejectedValue(new Error('locked')) };
548
+ fetchMock()
549
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
550
+ .mockResolvedValueOnce(rejected)
551
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=new.tomcat1']))
552
+ .mockResolvedValueOnce(resp(200, [], {}));
553
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM({ id: '1' });
554
+ expect(fetchMock()).toHaveBeenCalledTimes(4);
555
+ });
556
+ });
557
+ describe('cookie names containing regex metacharacters', () => {
558
+ it('should extract a cookie whose name contains "+"', async () => {
559
+ const u = uniqueUser();
560
+ fetchMock()
561
+ .mockResolvedValueOnce(resp(200, ['SID+=abc; Path=/']))
562
+ .mockResolvedValueOnce(resp(200, [], {}));
563
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'SID+' })).sendToFlexPLM(payload);
564
+ expect(callHeaders(1).Cookie).toBe('SID+=abc');
565
+ });
566
+ it('should treat "." literally and not match a look-alike cookie', async () => {
567
+ const u = uniqueUser();
568
+ fetchMock()
569
+ .mockResolvedValueOnce(resp(200, ['AxB=wrong; Path=/']))
570
+ .mockResolvedValueOnce(resp(200, [], {}));
571
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'A.B' })).sendToFlexPLM(payload);
572
+ expect(callHeaders(1).Cookie).toBeUndefined();
573
+ });
574
+ });
575
+ describe('concurrent session acquisition', () => {
576
+ const flush = () => new Promise(r => setImmediate(r));
577
+ const isColdGet = (call) => call[1].method === 'GET' && !call[1].headers.Cookie;
578
+ it('should not let a late response using an older cookie roll back a refreshed session', async () => {
579
+ const u = uniqueUser();
580
+ const cfg = () => sessionConfig(u);
581
+ // seed the cache with 'old'
582
+ fetchMock()
583
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=old']))
584
+ .mockResolvedValueOnce(resp(200, [], {}));
585
+ await new flexplm_connect_1.FlexPLMConnect(cfg()).sendToFlexPLM(payload);
586
+ let lateGetsSeen = 0;
587
+ let releaseLate;
588
+ const lateGate = new Promise(r => { releaseLate = r; });
589
+ fetchMock().mockReset();
590
+ fetchMock().mockImplementation(async (_url, opts) => {
591
+ const cookie = opts.headers.Cookie;
592
+ if (opts.method === 'GET' && cookie === 'JSESSIONID=old') {
593
+ if (++lateGetsSeen === 1)
594
+ return resp(403, []); // event A: old rejected
595
+ await lateGate; // event B: slow 200, no Set-Cookie
596
+ return resp(200, []);
597
+ }
598
+ if (opts.method === 'GET' && !cookie)
599
+ return resp(200, ['JSESSIONID=new']); // A's fresh acquisition
600
+ if (opts.method === 'GET')
601
+ return resp(200, []);
602
+ return resp(200, [], {});
603
+ });
604
+ const a = new flexplm_connect_1.FlexPLMConnect(cfg()).sendToFlexPLM(payload);
605
+ const b = new flexplm_connect_1.FlexPLMConnect(cfg()).sendToFlexPLM(payload);
606
+ await a;
607
+ releaseLate();
608
+ await b;
609
+ fetchMock().mockClear();
610
+ await new flexplm_connect_1.FlexPLMConnect(cfg()).getCSRF();
611
+ expect(callHeaders(0).Cookie).toBe('JSESSIONID=new'); // B must not have stored 'old' again
612
+ });
613
+ it('should share one session across concurrent events on a cold cache', async () => {
614
+ const u = uniqueUser();
615
+ let release;
616
+ const gate = new Promise(r => { release = r; });
617
+ fetchMock().mockImplementation(async (_url, opts) => {
618
+ if (opts.method === 'GET' && !opts.headers.Cookie) {
619
+ await gate;
620
+ return resp(200, ['JSESSIONID=abc.tomcat1']);
621
+ }
622
+ return resp(200, []);
623
+ });
624
+ const all = Promise.all([1, 2, 3].map(() => new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF()));
625
+ await flush();
626
+ expect(fetchMock()).toHaveBeenCalledTimes(1); // the other two are waiting for the first acquisition
627
+ release();
628
+ const results = await all;
629
+ expect(fetchMock().mock.calls.filter(isColdGet)).toHaveLength(1);
630
+ results.forEach(r => expect(r.sessionCookie).toBe('abc.tomcat1'));
631
+ });
632
+ it('should not make a waiter inherit the failure of the first acquisition', async () => {
633
+ const u = uniqueUser();
634
+ let release;
635
+ const gate = new Promise(r => { release = r; });
636
+ fetchMock()
637
+ .mockImplementationOnce(async () => { await gate; return resp(500, []); })
638
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']));
639
+ const first = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF();
640
+ const firstResult = first.catch(e => e);
641
+ const second = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF();
642
+ await flush();
643
+ release();
644
+ expect(await firstResult).toBeInstanceOf(Error);
645
+ expect((await second).sessionCookie).toBe('abc.tomcat1');
646
+ });
647
+ it('should create only one replacement session for parallel events rejected with 403', async () => {
648
+ const u = uniqueUser();
649
+ fetchMock().mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']));
650
+ await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).getCSRF(); // prime the cache with the old session
651
+ fetchMock().mockReset();
652
+ fetchMock().mockImplementation(async (_url, opts) => {
653
+ const cookie = opts.headers.Cookie;
654
+ if (opts.method === 'POST')
655
+ return cookie === 'JSESSIONID=new.tomcat1' ? resp(200, [], {}) : resp(403, [], {});
656
+ if (!cookie)
657
+ return resp(200, ['JSESSIONID=new.tomcat1']);
658
+ return resp(200, []);
659
+ });
660
+ await Promise.all([1, 2, 3].map(() => new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload)));
661
+ expect(fetchMock().mock.calls.filter(isColdGet)).toHaveLength(1);
662
+ });
663
+ });
324
664
  });
325
665
  describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
326
666
  const payload = { id: '123' };
327
- it('sends no Cookie header and makes exactly one GET + one POST', async () => {
667
+ it('should send no Cookie header and make exactly one GET + one POST', async () => {
328
668
  const u = uniqueUser();
329
669
  fetchMock()
330
670
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1'])) // Set-Cookie present but must be ignored
@@ -338,7 +678,7 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
338
678
  expect(callHeaders(i).Cookie).toBeUndefined();
339
679
  }
340
680
  });
341
- it('does not retry on 403 (behaves exactly as before)', async () => {
681
+ it('should not retry on 403 (behaves exactly as before)', async () => {
342
682
  const u = uniqueUser();
343
683
  fetchMock()
344
684
  .mockResolvedValueOnce(resp(200))
@@ -347,7 +687,7 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
347
687
  .rejects.toThrow('Error sending data to FlexPLM:status: 403');
348
688
  expect(fetchMock()).toHaveBeenCalledTimes(2);
349
689
  });
350
- it('explicit enabled:false behaves the same as not configured', async () => {
690
+ it('should behave the same as not configured when enabled is false', async () => {
351
691
  const u = uniqueUser();
352
692
  fetchMock()
353
693
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -356,8 +696,8 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
356
696
  expect(callHeaders(1).Cookie).toBeUndefined();
357
697
  });
358
698
  });
359
- describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)', () => {
360
- it('sends the cached cookie on image/GET requests', async () => {
699
+ describe('FlexPLMConnect.getRequest session reuse', () => {
700
+ it('should send the cached cookie on image/GET requests', async () => {
361
701
  const u = uniqueUser();
362
702
  fetchMock()
363
703
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1'])) // csrf GET primes the cache
@@ -368,7 +708,7 @@ describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)'
368
708
  await connect.getRequest();
369
709
  expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
370
710
  });
371
- it('retries once without the cookie when the cached session is rejected on getRequest', async () => {
711
+ it('should retry once without the cookie when the cached session is rejected on getRequest', async () => {
372
712
  const u = uniqueUser();
373
713
  fetchMock()
374
714
  .mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
@@ -381,5 +721,20 @@ describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)'
381
721
  expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
382
722
  expect(callHeaders(3).Cookie).toBeUndefined();
383
723
  });
724
+ it('should not re-store the rejected cookie when the retry issues no Set-Cookie', async () => {
725
+ const u = uniqueUser();
726
+ fetchMock()
727
+ .mockResolvedValueOnce(resp(200, ['JSESSIONID=stale.tomcat1']))
728
+ .mockResolvedValueOnce(resp(200, [], {}))
729
+ .mockResolvedValueOnce(resp(401, [])) // getRequest with the cached cookie
730
+ .mockResolvedValueOnce(resp(200, [], { items: [] })) // retry succeeds, no Set-Cookie
731
+ .mockResolvedValueOnce(resp(200, [], { items: [] })); // next getRequest
732
+ const connect = new flexplm_connect_1.FlexPLMConnect(sessionConfig(u));
733
+ await connect.sendToFlexPLM({ id: '1' });
734
+ await connect.getRequest();
735
+ await connect.getRequest();
736
+ expect(callHeaders(3).Cookie).toBeUndefined();
737
+ expect(callHeaders(4).Cookie).toBeUndefined(); // stale cookie must not be sent again
738
+ });
384
739
  });
385
740
  // Added for Session reuse - ET-390 - Ends
@@ -0,0 +1,40 @@
1
+ import { FCConfig } from '../interfaces/interfaces';
2
+ export declare class SessionRejectedError extends Error {
3
+ }
4
+ /** Owns the FlexPLM session cookie cache: options, keying, expiry, in-flight acquisition sharing and Set-Cookie parsing. */
5
+ export declare class FlexPLMSessionManager {
6
+ private config;
7
+ constructor(config: FCConfig);
8
+ get options(): {
9
+ enabled: boolean;
10
+ ttlMs: number;
11
+ cookieName: string;
12
+ retryStatuses: number[];
13
+ };
14
+ /**
15
+ * Waits until no cold session acquisition is running for this key.
16
+ * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
17
+ * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
18
+ */
19
+ waitForInFlightAcquisition(): Promise<void> | undefined;
20
+ /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
21
+ runColdAcquisition<T>(acquire: () => Promise<T>): Promise<T>;
22
+ private getSessionKey;
23
+ getCachedSession(): string | undefined;
24
+ /**
25
+ * Compare-and-set: stores `cookie` only if the cache still holds the cookie this request was sent with
26
+ * (`sentCookie`, undefined for a cold acquisition = nothing cached). A late response must not roll back or
27
+ * resurrect a session that a concurrent event already replaced or cleared.
28
+ * @returns true if the cache was updated
29
+ */
30
+ storeSession(cookie: string, sentCookie: string | undefined): boolean;
31
+ clearSession(): void;
32
+ /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
33
+ extractSessionCookie(response: Response): string | undefined;
34
+ /**
35
+ * Builds the Cookie header value from the configured static Cookie header and the managed session cookie.
36
+ * With session reuse enabled only the managed cookie is replaced/removed; every other static cookie pair is kept.
37
+ * With session reuse disabled the static value is returned unchanged.
38
+ */
39
+ buildCookieHeader(staticCookie?: string, sessionCookie?: string): string | undefined;
40
+ }
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FlexPLMSessionManager = exports.SessionRejectedError = void 0;
4
+ // Module-level on purpose: a new FlexPLMConnect (and session manager) is constructed for every event.
5
+ const sessionCache = new Map();
6
+ // Upper bound so users/option combinations that are never requested again cannot accumulate cookies forever.
7
+ const MAX_CACHED_SESSIONS = 100;
8
+ const pruneSessionCache = () => {
9
+ const now = Date.now();
10
+ for (const [key, session] of sessionCache) {
11
+ if (session.expiresAt <= now)
12
+ sessionCache.delete(key);
13
+ }
14
+ // Still full: drop the oldest entries (Map keeps insertion order).
15
+ while (sessionCache.size >= MAX_CACHED_SESSIONS) {
16
+ sessionCache.delete(sessionCache.keys().next().value);
17
+ }
18
+ };
19
+ // In-flight cold session acquisitions per key, so concurrent events share one FlexPLM session instead of each creating their own.
20
+ const inFlightAcquisitions = new Map();
21
+ const DEFAULT_SESSION_COOKIE_NAME = 'JSESSIONID';
22
+ const DEFAULT_SESSION_TTL_SECONDS = 900;
23
+ const DEFAULT_RETRY_STATUSES = [401, 403];
24
+ const COOKIE_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
25
+ // Thrown internally when a cached cookie was rejected, so the caller can re-acquire under the lock.
26
+ class SessionRejectedError extends Error {
27
+ }
28
+ exports.SessionRejectedError = SessionRejectedError;
29
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ const parseRetryStatuses = (value) => {
31
+ let list = [];
32
+ if (Array.isArray(value))
33
+ list = value;
34
+ else if (typeof value === 'string')
35
+ list = value.split(',');
36
+ else if (value !== undefined && value !== null)
37
+ list = [value];
38
+ const statuses = list
39
+ .map(status => (typeof status === 'string' ? status.trim() : status))
40
+ .filter(status => status !== '' && status !== null && status !== undefined)
41
+ .map(Number)
42
+ // Only client-error statuses can mean "session rejected". 2xx/5xx would resend events the server already processed.
43
+ .filter(status => Number.isInteger(status) && status >= 400 && status <= 499);
44
+ return statuses.length ? statuses : DEFAULT_RETRY_STATUSES;
45
+ };
46
+ const parseTtlSeconds = (value) => {
47
+ const ttl = Number(value);
48
+ return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SECONDS;
49
+ };
50
+ const parseCookieName = (value) => {
51
+ const name = typeof value === 'string' ? value.trim() : '';
52
+ return COOKIE_NAME_TOKEN.test(name) ? name : DEFAULT_SESSION_COOKIE_NAME;
53
+ };
54
+ /** Owns the FlexPLM session cookie cache: options, keying, expiry, in-flight acquisition sharing and Set-Cookie parsing. */
55
+ class FlexPLMSessionManager {
56
+ constructor(config) {
57
+ this.config = config;
58
+ }
59
+ get options() {
60
+ const sessionReuseConfig = this.config?.flexplmConnect?.sessionReuse ?? {};
61
+ return {
62
+ enabled: sessionReuseConfig.enabled === true || String(sessionReuseConfig.enabled).toLowerCase() === 'true',
63
+ ttlMs: parseTtlSeconds(sessionReuseConfig.ttlSeconds) * 1000,
64
+ cookieName: parseCookieName(sessionReuseConfig.cookieName),
65
+ retryStatuses: parseRetryStatuses(sessionReuseConfig.retryStatuses),
66
+ };
67
+ }
68
+ /**
69
+ * Waits until no cold session acquisition is running for this key.
70
+ * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
71
+ * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
72
+ */
73
+ waitForInFlightAcquisition() {
74
+ const key = this.getSessionKey();
75
+ if (!inFlightAcquisitions.has(key))
76
+ return undefined;
77
+ return (async () => {
78
+ let pending = inFlightAcquisitions.get(key);
79
+ while (pending) {
80
+ await pending.catch(() => undefined);
81
+ pending = inFlightAcquisitions.get(key);
82
+ }
83
+ })();
84
+ }
85
+ /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
86
+ async runColdAcquisition(acquire) {
87
+ const key = this.getSessionKey();
88
+ const acquisition = acquire();
89
+ inFlightAcquisitions.set(key, acquisition);
90
+ try {
91
+ return await acquisition;
92
+ }
93
+ finally {
94
+ if (inFlightAcquisitions.get(key) === acquisition)
95
+ inFlightAcquisitions.delete(key);
96
+ }
97
+ }
98
+ getSessionKey() {
99
+ // Options that change the cached value are part of the key; JSON avoids '|' collisions between fields.
100
+ const { cookieName, ttlMs } = this.options;
101
+ return JSON.stringify([
102
+ this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment, cookieName, ttlMs,
103
+ ]);
104
+ }
105
+ getCachedSession() {
106
+ if (!this.options.enabled)
107
+ return undefined;
108
+ const key = this.getSessionKey();
109
+ const cachedSessValue = sessionCache.get(key);
110
+ if (!cachedSessValue)
111
+ return undefined;
112
+ if (cachedSessValue.expiresAt <= Date.now()) {
113
+ sessionCache.delete(key);
114
+ return undefined;
115
+ }
116
+ return cachedSessValue.cookie;
117
+ }
118
+ /**
119
+ * Compare-and-set: stores `cookie` only if the cache still holds the cookie this request was sent with
120
+ * (`sentCookie`, undefined for a cold acquisition = nothing cached). A late response must not roll back or
121
+ * resurrect a session that a concurrent event already replaced or cleared.
122
+ * @returns true if the cache was updated
123
+ */
124
+ storeSession(cookie, sentCookie) {
125
+ const key = this.getSessionKey();
126
+ if (sessionCache.get(key)?.cookie !== sentCookie)
127
+ return false;
128
+ // sliding expiry: Windchill's session timeout is also sliding
129
+ // delete first so a refreshed entry moves to the newest position (oldest-first eviction then acts as LRU)
130
+ sessionCache.delete(key);
131
+ pruneSessionCache();
132
+ sessionCache.set(key, { cookie, expiresAt: Date.now() + this.options.ttlMs });
133
+ return true;
134
+ }
135
+ clearSession() {
136
+ sessionCache.delete(this.getSessionKey());
137
+ }
138
+ /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
139
+ extractSessionCookie(response) {
140
+ const respHeader = response?.headers;
141
+ if (!respHeader)
142
+ return undefined;
143
+ let raw = [];
144
+ if (typeof respHeader.getSetCookie === 'function') {
145
+ raw = respHeader.getSetCookie();
146
+ }
147
+ else if (typeof respHeader.get === 'function' && respHeader.get('set-cookie')) {
148
+ raw = [respHeader.get('set-cookie')];
149
+ }
150
+ const name = this.options.cookieName;
151
+ const regexFilter = new RegExp('(?:^|[,\\s])' + escapeRegExp(name) + '=([^;,\\s]+)', 'g');
152
+ let jSession;
153
+ for (const line of raw) {
154
+ let cookieNameMatcher = regexFilter.exec(line);
155
+ while (cookieNameMatcher !== null) {
156
+ jSession = cookieNameMatcher[1];
157
+ cookieNameMatcher = regexFilter.exec(line);
158
+ }
159
+ }
160
+ return jSession;
161
+ }
162
+ /**
163
+ * Builds the Cookie header value from the configured static Cookie header and the managed session cookie.
164
+ * With session reuse enabled only the managed cookie is replaced/removed; every other static cookie pair is kept.
165
+ * With session reuse disabled the static value is returned unchanged.
166
+ */
167
+ buildCookieHeader(staticCookie, sessionCookie) {
168
+ if (!this.options.enabled)
169
+ return staticCookie || undefined;
170
+ const name = this.options.cookieName;
171
+ const pairs = (staticCookie ?? '')
172
+ .split(';')
173
+ .map(pair => pair.trim())
174
+ .filter(pair => pair && pair.split('=')[0].trim() !== name);
175
+ if (sessionCookie)
176
+ pairs.push(`${name}=${sessionCookie}`);
177
+ return pairs.length ? pairs.join('; ') : undefined;
178
+ }
179
+ }
180
+ exports.FlexPLMSessionManager = FlexPLMSessionManager;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const flexplm_session_manager_1 = require("./flexplm-session-manager");
4
+ let userCounter = 0;
5
+ const newManager = () => {
6
+ const user = 'mgr-user-' + (++userCounter);
7
+ const config = {
8
+ apiHost: 'https://flexplm.example.com',
9
+ urlContext: '/FlexPLM',
10
+ vibeEventEndpoint: '/api/events',
11
+ csrfEndpoint: '/csrf',
12
+ plmEnviornment: 'test',
13
+ userName: () => user,
14
+ password: () => 'pass',
15
+ itemPreDevelopmentLifecycleStages: [],
16
+ flexplmConnect: { sessionReuse: { enabled: true } },
17
+ };
18
+ return new flexplm_session_manager_1.FlexPLMSessionManager(config);
19
+ };
20
+ describe('FlexPLMSessionManager.storeSession (compare-and-set)', () => {
21
+ it('should store a cold acquisition when nothing is cached', () => {
22
+ const m = newManager();
23
+ expect(m.storeSession('a', undefined)).toBe(true);
24
+ expect(m.getCachedSession()).toBe('a');
25
+ });
26
+ it('should not let a cold acquisition overwrite an entry that appeared meanwhile', () => {
27
+ const m = newManager();
28
+ m.storeSession('a', undefined);
29
+ expect(m.storeSession('b', undefined)).toBe(false);
30
+ expect(m.getCachedSession()).toBe('a');
31
+ });
32
+ it('should replace or refresh the entry while it still holds the cookie that was sent', () => {
33
+ const m = newManager();
34
+ m.storeSession('old', undefined);
35
+ expect(m.storeSession('new', 'old')).toBe(true);
36
+ expect(m.getCachedSession()).toBe('new');
37
+ expect(m.storeSession('new', 'new')).toBe(true); // sliding refresh with the same cookie
38
+ });
39
+ it('should ignore a late response that was sent with an older cookie', () => {
40
+ const m = newManager();
41
+ m.storeSession('old', undefined);
42
+ m.storeSession('new', 'old');
43
+ expect(m.storeSession('old', 'old')).toBe(false);
44
+ expect(m.getCachedSession()).toBe('new');
45
+ });
46
+ it('should not resurrect a session that was cleared meanwhile', () => {
47
+ const m = newManager();
48
+ m.storeSession('old', undefined);
49
+ m.clearSession();
50
+ expect(m.storeSession('old', 'old')).toBe(false);
51
+ expect(m.getCachedSession()).toBeUndefined();
52
+ });
53
+ });
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.e0ee6a0",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",