@contrail/flexplm 1.7.7-alpha.99f62a7 → 1.7.7-alpha.a0d86c7

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,15 +1,3 @@
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
- }
13
1
  export interface FCConfig {
14
2
  apiKey?: string;
15
3
  orgSlug?: string;
@@ -17,7 +5,11 @@ export interface FCConfig {
17
5
  configFile?: string;
18
6
  eventObjectClassParam?: string;
19
7
  eventFlexTypePathParam?: string;
20
- flexplmConnect?: FlexPLMConnectConfig;
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;
21
13
  taskId?: string;
22
14
  apiHost: string;
23
15
  userName(): string;
@@ -1,13 +1,28 @@
1
1
  import { FCConfig, FlexPLMResponseData, PayloadType } from '../interfaces/interfaces';
2
2
  export declare class FlexPLMConnect {
3
3
  private config;
4
- private sessions;
5
4
  private vibeEventEndpoint;
6
5
  private staticHeaders;
7
6
  payloadSendAsArray: boolean;
8
7
  constructor(_config: FCConfig, endPoint?: any, payloadAsArray?: any);
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;
17
+ private getSessionKey;
9
18
  /** Releases the connection of a rejected response before it is replaced by a retry. */
10
19
  private discardBody;
20
+ private getCachedSession;
21
+ private storeSession;
22
+ private clearSession;
23
+ /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
24
+ private extractSessionCookie;
25
+ private cookieHeader;
11
26
  private getRequestOptions;
12
27
  getCSRF(getNewSession?: boolean, rejectedCookie?: string): any;
13
28
  private fetchCSRF;
@@ -2,23 +2,62 @@
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
- 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***';
5
+ // Module-level on purpose: a new FlexPLMConnect is constructed for every event. Declaring for Session reusing use case.
6
+ const sessionCache = new Map();
7
+ // Upper bound so users/option combinations that are never requested again cannot accumulate cookies forever.
8
+ const MAX_CACHED_SESSIONS = 100;
9
+ const pruneSessionCache = () => {
10
+ const now = Date.now();
11
+ for (const [key, session] of sessionCache) {
12
+ if (session.expiresAt <= now)
13
+ sessionCache.delete(key);
13
14
  }
15
+ // Still full: drop the oldest entries (Map keeps insertion order).
16
+ while (sessionCache.size >= MAX_CACHED_SESSIONS) {
17
+ sessionCache.delete(sessionCache.keys().next().value);
18
+ }
19
+ };
20
+ // In-flight cold session acquisitions per key, so concurrent events share one FlexPLM session instead of each creating their own.
21
+ const inFlightAcquisitions = new Map();
22
+ const DEFAULT_SESSION_COOKIE_NAME = 'JSESSIONID';
23
+ const DEFAULT_SESSION_TTL_SECONDS = 900;
24
+ const DEFAULT_RETRY_STATUSES = [401, 403];
25
+ const COOKIE_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
26
+ // Thrown internally when a cached cookie was rejected, so the caller can re-acquire under the lock.
27
+ class SessionRejectedError extends Error {
28
+ }
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;
14
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
+ // Added for Session reuse - ET-390 - Ends
15
55
  class FlexPLMConnect {
16
56
  constructor(_config, endPoint = undefined, payloadAsArray = undefined) {
17
57
  this.vibeEventEndpoint = '';
18
58
  this.staticHeaders = undefined;
19
59
  this.payloadSendAsArray = true;
20
60
  this.config = _config;
21
- this.sessions = new flexplm_session_manager_1.FlexPLMSessionManager(_config);
22
61
  this.vibeEventEndpoint = (endPoint)
23
62
  ? endPoint
24
63
  : this.config.vibeEventEndpoint;
@@ -29,7 +68,70 @@ class FlexPLMConnect {
29
68
  this.staticHeaders = this.config?.flexplmConnect?.staticHeaders;
30
69
  }
31
70
  }
32
- // ET-390 - Session reuse
71
+ /////////////////////////////////////////////////////////////////////////////
72
+ ///////// ET-390 - Session reuse: start
73
+ /////////////////////////////////////////////////////////////////////////////
74
+ /*
75
+ private get sessionOptions() {
76
+ const sessReuseBool = this.config?.flexplmConnect?.sessionReuse;
77
+ console.info('sessReuseBool value is --> ' + sessReuseBool);
78
+ console.info('enabled value is --> ' + sessReuseBool?.enabled);
79
+ return {
80
+ enabled: sessReuseBool?.enabled === true || String(sessReuseBool?.enabled).toLowerCase() === 'true',
81
+ ttlMs: (sessReuseBool?.ttlSeconds ?? 900) * 1000,
82
+ cookieName: sessReuseBool?.cookieName ?? 'JSESSIONID',
83
+ retryStatuses: (sessReuseBool?.retryStatuses ?? [401, 403]) as number[],
84
+ };
85
+ }
86
+ */
87
+ get sessionOptions() {
88
+ const cfg = this.config ?? {};
89
+ const sessReuseEntries = (name) => cfg[`flexplmConnect.sessionReuse.${name}`];
90
+ const enabled = sessReuseEntries('enabled');
91
+ return {
92
+ enabled: enabled === true || String(enabled).toLowerCase() === 'true',
93
+ ttlMs: parseTtlSeconds(sessReuseEntries('ttlSeconds')) * 1000,
94
+ cookieName: parseCookieName(sessReuseEntries('cookieName')),
95
+ retryStatuses: parseRetryStatuses(sessReuseEntries('retryStatuses')),
96
+ };
97
+ }
98
+ /**
99
+ * Waits until no cold session acquisition is running for this key.
100
+ * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
101
+ * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
102
+ */
103
+ waitForInFlightAcquisition() {
104
+ const key = this.getSessionKey();
105
+ if (!inFlightAcquisitions.has(key))
106
+ return undefined;
107
+ return (async () => {
108
+ let pending = inFlightAcquisitions.get(key);
109
+ while (pending) {
110
+ await pending.catch(() => undefined);
111
+ pending = inFlightAcquisitions.get(key);
112
+ }
113
+ })();
114
+ }
115
+ /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
116
+ async runColdAcquisition(acquire) {
117
+ const key = this.getSessionKey();
118
+ const acquisition = acquire();
119
+ inFlightAcquisitions.set(key, acquisition);
120
+ try {
121
+ return await acquisition;
122
+ }
123
+ finally {
124
+ if (inFlightAcquisitions.get(key) === acquisition)
125
+ inFlightAcquisitions.delete(key);
126
+ }
127
+ }
128
+ getSessionKey() {
129
+ // Options that change the cached value are part of the key; JSON avoids '|' collisions between fields.
130
+ const { cookieName, ttlMs } = this.sessionOptions;
131
+ return JSON.stringify([
132
+ this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment, cookieName, ttlMs,
133
+ ]);
134
+ }
33
135
  /** Releases the connection of a rejected response before it is replaced by a retry. */
34
136
  async discardBody(response) {
35
137
  try {
@@ -40,6 +142,60 @@ class FlexPLMConnect {
40
142
  // best effort: nothing to do if the body is already consumed or locked
41
143
  }
42
144
  }
145
+ getCachedSession() {
146
+ if (!this.sessionOptions.enabled)
147
+ return undefined;
148
+ const key = this.getSessionKey();
149
+ const cachedSessValue = sessionCache.get(key);
150
+ if (!cachedSessValue)
151
+ return undefined;
152
+ if (cachedSessValue.expiresAt <= Date.now()) {
153
+ sessionCache.delete(key);
154
+ return undefined;
155
+ }
156
+ return cachedSessValue.cookie;
157
+ }
158
+ storeSession(cookie) {
159
+ // sliding expiry: Windchill's session timeout is also sliding
160
+ const key = this.getSessionKey();
161
+ // delete first so a refreshed entry moves to the newest position (oldest-first eviction then acts as LRU)
162
+ sessionCache.delete(key);
163
+ pruneSessionCache();
164
+ sessionCache.set(key, { cookie, expiresAt: Date.now() + this.sessionOptions.ttlMs });
165
+ }
166
+ clearSession() {
167
+ sessionCache.delete(this.getSessionKey());
168
+ }
169
+ /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
170
+ extractSessionCookie(response) {
171
+ const respHeader = response?.headers;
172
+ if (!respHeader)
173
+ return undefined;
174
+ let raw = [];
175
+ if (typeof respHeader.getSetCookie === 'function') {
176
+ raw = respHeader.getSetCookie();
177
+ }
178
+ else if (typeof respHeader.get === 'function' && respHeader.get('set-cookie')) {
179
+ raw = [respHeader.get('set-cookie')];
180
+ }
181
+ const name = this.sessionOptions.cookieName;
182
+ const regexFilter = new RegExp('(?:^|[,\\s])' + escapeRegExp(name) + '=([^;,\\s]+)', 'g');
183
+ let jSession;
184
+ for (const line of raw) {
185
+ let cookieNameMatcher = regexFilter.exec(line);
186
+ while (cookieNameMatcher !== null) {
187
+ jSession = cookieNameMatcher[1];
188
+ cookieNameMatcher = regexFilter.exec(line);
189
+ }
190
+ }
191
+ return jSession;
192
+ }
193
+ cookieHeader(sessionCookie) {
194
+ return sessionCookie ? `${this.sessionOptions.cookieName}=${sessionCookie}` : undefined;
195
+ }
196
+ /////////////////////////////////////////////////////////////////////////////
197
+ ///////// ET-390 - Session reuse: end
198
+ /////////////////////////////////////////////////////////////////////////////
43
199
  /////////////////////////////////////////////////////////////////////////////
44
200
  ///////// Custom getRequestOptions: start
45
201
  /////////////////////////////////////////////////////////////////////////////
@@ -57,21 +213,17 @@ class FlexPLMConnect {
57
213
  Object.assign(csrfOptions.headers, this.staticHeaders);
58
214
  }
59
215
  // Added for Session reuse - ET-390 - Starts
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]);
216
+ const cookie = this.cookieHeader(sessionCookie);
66
217
  if (cookie) {
67
- headers['Cookie'] = cookie;
218
+ csrfOptions.headers['Cookie'] = cookie;
68
219
  }
69
220
  // Added for Session reuse - ET-390 - Ends
70
221
  if (app_framework_1.Logger.isInfoOn()) {
71
222
  const logOptions = JSON.parse(JSON.stringify(csrfOptions));
72
223
  logOptions['headers']['Authorization'] = logOptions?.headers?.Authorization.substring(0, 9);
73
224
  // Added for Session reuse - ET-390 - Starts
74
- maskCookieHeaders(logOptions.headers);
225
+ if (logOptions.headers['Cookie'])
226
+ logOptions.headers['Cookie'] = '***masked***';
75
227
  // Added for Session reuse - ET-390 - Ends
76
228
  console.info('csrfOptions: ' + JSON.stringify(logOptions));
77
229
  }
@@ -85,30 +237,30 @@ class FlexPLMConnect {
85
237
  // rejectedCookie: the cookie that was rejected by the caller, so a refresh already done by a concurrent event is not thrown away
86
238
  async getCSRF(getNewSession = false, rejectedCookie) {
87
239
  // Added for Session reuse - ET-390 - Starts
88
- if (!this.sessions.options.enabled)
240
+ if (!this.sessionOptions.enabled)
89
241
  return this.fetchCSRF(undefined);
90
- for (let wait = this.sessions.waitForInFlightAcquisition(); wait; wait = this.sessions.waitForInFlightAcquisition())
242
+ for (let wait = this.waitForInFlightAcquisition(); wait; wait = this.waitForInFlightAcquisition())
91
243
  await wait;
92
244
  if (getNewSession) {
93
- const current = this.sessions.getCachedSession();
245
+ const current = this.getCachedSession();
94
246
  if (rejectedCookie === undefined || !current || current === rejectedCookie)
95
- this.sessions.clearSession();
247
+ this.clearSession();
96
248
  }
97
- const cached = this.sessions.getCachedSession();
249
+ const cached = this.getCachedSession();
98
250
  if (cached) {
99
251
  try {
100
252
  return await this.fetchCSRF(cached);
101
253
  }
102
254
  catch (e) {
103
- if (!(e instanceof flexplm_session_manager_1.SessionRejectedError))
255
+ if (!(e instanceof SessionRejectedError))
104
256
  throw e;
105
257
  // 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();
258
+ if (this.getCachedSession() === cached)
259
+ this.clearSession();
108
260
  return this.getCSRF();
109
261
  }
110
262
  }
111
- return this.sessions.runColdAcquisition(() => this.fetchCSRF(undefined));
263
+ return this.runColdAcquisition(() => this.fetchCSRF(undefined));
112
264
  // Added for Session reuse - ET-390 - Ends
113
265
  }
114
266
  async fetchCSRF(sentCookie) {
@@ -120,10 +272,10 @@ class FlexPLMConnect {
120
272
  const csrfOptions = this.getRequestOptions('GET', sentCookie);
121
273
  const response = await fetch(csrfURL, csrfOptions);
122
274
  // Added for Session reuse - ET-390 - Starts
123
- if (sentCookie && this.sessions.options.retryStatuses.includes(response.status)) {
275
+ if (sentCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
124
276
  console.warn('Cached FlexPLM session rejected on csrf call (status ' + response.status + '). Retrying with a new session.');
125
277
  await this.discardBody(response);
126
- throw new flexplm_session_manager_1.SessionRejectedError();
278
+ throw new SessionRejectedError();
127
279
  }
128
280
  // Added for Session reuse - ET-390 - Ends
129
281
  if (response.status >= 300) {
@@ -137,10 +289,10 @@ class FlexPLMConnect {
137
289
  // Added for Session reuse - ET-390 - Starts
138
290
  // If the cookie we sent was invalid, Windchill answers with a fresh Set-Cookie --> replace the cache.
139
291
  let sessionCookie = sentCookie;
140
- if (this.sessions.options.enabled) {
141
- sessionCookie = this.sessions.extractSessionCookie(response) ?? sentCookie;
292
+ if (this.sessionOptions.enabled) {
293
+ sessionCookie = this.extractSessionCookie(response) ?? sentCookie;
142
294
  if (sessionCookie)
143
- this.sessions.storeSession(sessionCookie, sentCookie);
295
+ this.storeSession(sessionCookie);
144
296
  }
145
297
  // Added for Session reuse - ET-390 - Ends
146
298
  try {
@@ -176,7 +328,8 @@ class FlexPLMConnect {
176
328
  const logOptions = JSON.parse(JSON.stringify(xferOptions));
177
329
  if (logOptions?.headers?.Authorization)
178
330
  logOptions.headers.Authorization = logOptions?.headers?.Authorization.substring(0, 9);
179
- maskCookieHeaders(logOptions?.headers);
331
+ if (logOptions?.headers?.Cookie)
332
+ logOptions.headers.Cookie = '***masked***';
180
333
  if (csrf?.nonce_key)
181
334
  logOptions.headers[csrf.nonce_key] = csrf.nonce ? `***masked(length:${csrf.nonce.length})***` : '***masked(empty)***';
182
335
  console.info('csrfOptions: ' + JSON.stringify(logOptions));
@@ -203,7 +356,7 @@ class FlexPLMConnect {
203
356
  /// Added for Session reuse - ET-390 - Starts
204
357
  // const response = await this.sendRequest(csrf, payload);
205
358
  let response = await this.sendRequest(csrf, payload);
206
- if (csrf.sessionCookie && this.sessions.options.retryStatuses.includes(response.status)) {
359
+ if (csrf.sessionCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
207
360
  console.warn('FlexPLM rejected session/nonce (status ' + response.status + '). Getting a new session and retrying once.');
208
361
  await this.discardBody(response);
209
362
  csrf = await this.getCSRF(true, csrf.sessionCookie);
@@ -264,30 +417,30 @@ class FlexPLMConnect {
264
417
  const vibeEventsURL = this.config.apiHost + urlContext + path;
265
418
  // Added for Session reuse - ET-390 - Starts
266
419
  const attempt = async () => {
267
- if (!this.sessions.options.enabled) {
420
+ if (!this.sessionOptions.enabled) {
268
421
  return { response: await fetch(vibeEventsURL, this.getRequestOptions('GET')), sent: undefined };
269
422
  }
270
- for (let wait = this.sessions.waitForInFlightAcquisition(); wait; wait = this.sessions.waitForInFlightAcquisition())
423
+ for (let wait = this.waitForInFlightAcquisition(); wait; wait = this.waitForInFlightAcquisition())
271
424
  await wait;
272
- const sent = this.sessions.getCachedSession();
425
+ const sent = this.getCachedSession();
273
426
  const call = async () => {
274
427
  const res = await fetch(vibeEventsURL, this.getRequestOptions('GET', sent));
275
428
  // 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;
429
+ if (!(sent && this.sessionOptions.retryStatuses.includes(res.status))) {
430
+ const cookie = this.extractSessionCookie(res) ?? sent;
278
431
  if (cookie)
279
- this.sessions.storeSession(cookie, sent);
432
+ this.storeSession(cookie);
280
433
  }
281
434
  return { response: res, sent };
282
435
  };
283
- return sent ? call() : this.sessions.runColdAcquisition(call);
436
+ return sent ? call() : this.runColdAcquisition(call);
284
437
  };
285
438
  const first = await attempt();
286
439
  let response = first.response;
287
- if (first.sent && this.sessions.options.retryStatuses.includes(response.status)) {
440
+ if (first.sent && this.sessionOptions.retryStatuses.includes(response.status)) {
288
441
  await this.discardBody(response);
289
- if (this.sessions.getCachedSession() === first.sent)
290
- this.sessions.clearSession();
442
+ if (this.getCachedSession() === first.sent)
443
+ this.clearSession();
291
444
  response = (await attempt()).response;
292
445
  }
293
446
  // Added for Session reuse - ET-390 - Ends
@@ -39,8 +39,12 @@ 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, ... } } }
43
- 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
+ };
44
48
  const fetchMock = () => global.fetch;
45
49
  const callHeaders = (i) => fetchMock().mock.calls[i][1].headers;
46
50
  const callMethod = (i) => fetchMock().mock.calls[i][1].method;
@@ -354,60 +358,6 @@ describe('FlexPLMConnect session reuse', () => {
354
358
  isInfoOn.mockRestore();
355
359
  info.mockRestore();
356
360
  });
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
361
  describe('option normalisation', () => {
412
362
  const rejectedOnceThenOk = (u, opts) => {
413
363
  fetchMock()
@@ -575,41 +525,6 @@ describe('FlexPLMConnect session reuse', () => {
575
525
  describe('concurrent session acquisition', () => {
576
526
  const flush = () => new Promise(r => setImmediate(r));
577
527
  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
528
  it('should share one session across concurrent events on a cold cache', async () => {
614
529
  const u = uniqueUser();
615
530
  let release;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/flexplm",
3
- "version": "1.7.7-alpha.99f62a7",
3
+ "version": "1.7.7-alpha.a0d86c7",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -1,40 +0,0 @@
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
- }
@@ -1,192 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FlexPLMSessionManager = exports.SessionRejectedError = void 0;
4
- const crypto_1 = require("crypto");
5
- // Module-level on purpose: a new FlexPLMConnect (and session manager) is constructed for every event.
6
- const sessionCache = new Map();
7
- // Upper bound so users/option combinations that are never requested again cannot accumulate cookies forever.
8
- const MAX_CACHED_SESSIONS = 100;
9
- const pruneSessionCache = () => {
10
- const now = Date.now();
11
- for (const [key, session] of sessionCache) {
12
- if (session.expiresAt <= now)
13
- sessionCache.delete(key);
14
- }
15
- // Still full: drop the oldest entries (Map keeps insertion order).
16
- while (sessionCache.size >= MAX_CACHED_SESSIONS) {
17
- sessionCache.delete(sessionCache.keys().next().value);
18
- }
19
- };
20
- // In-flight cold session acquisitions per key, so concurrent events share one FlexPLM session instead of each creating their own.
21
- const inFlightAcquisitions = new Map();
22
- const SESSION_CACHE_DIGEST_KEY = (0, crypto_1.randomBytes)(32);
23
- const DEFAULT_SESSION_COOKIE_NAME = 'JSESSIONID';
24
- const DEFAULT_SESSION_TTL_SECONDS = 900;
25
- const DEFAULT_RETRY_STATUSES = [401, 403];
26
- const COOKIE_NAME_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
27
- // Thrown internally when a cached cookie was rejected, so the caller can re-acquire under the lock.
28
- class SessionRejectedError extends Error {
29
- }
30
- exports.SessionRejectedError = SessionRejectedError;
31
- const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
- const parseRetryStatuses = (value) => {
33
- let list = [];
34
- if (Array.isArray(value))
35
- list = value;
36
- else if (typeof value === 'string')
37
- list = value.split(',');
38
- else if (value !== undefined && value !== null)
39
- list = [value];
40
- const statuses = list
41
- .map(status => (typeof status === 'string' ? status.trim() : status))
42
- .filter(status => status !== '' && status !== null && status !== undefined)
43
- .map(Number)
44
- // Only client-error statuses can mean "session rejected". 2xx/5xx would resend events the server already processed.
45
- .filter(status => Number.isInteger(status) && status >= 400 && status <= 499);
46
- return statuses.length ? statuses : DEFAULT_RETRY_STATUSES;
47
- };
48
- const parseTtlSeconds = (value) => {
49
- const ttl = Number(value);
50
- return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SECONDS;
51
- };
52
- const parseCookieName = (value) => {
53
- const name = typeof value === 'string' ? value.trim() : '';
54
- return COOKIE_NAME_TOKEN.test(name) ? name : DEFAULT_SESSION_COOKIE_NAME;
55
- };
56
- /** Owns the FlexPLM session cookie cache: options, keying, expiry, in-flight acquisition sharing and Set-Cookie parsing. */
57
- class FlexPLMSessionManager {
58
- constructor(config) {
59
- this.config = config;
60
- }
61
- get options() {
62
- const sessionReuseConfig = this.config?.flexplmConnect?.sessionReuse ?? {};
63
- return {
64
- enabled: sessionReuseConfig.enabled === true || String(sessionReuseConfig.enabled).toLowerCase() === 'true',
65
- ttlMs: parseTtlSeconds(sessionReuseConfig.ttlSeconds) * 1000,
66
- cookieName: parseCookieName(sessionReuseConfig.cookieName),
67
- retryStatuses: parseRetryStatuses(sessionReuseConfig.retryStatuses),
68
- };
69
- }
70
- /**
71
- * Waits until no cold session acquisition is running for this key.
72
- * Callers must `await` this only when it returns a promise: awaiting even a resolved promise yields,
73
- * and the check-then-register in runColdAcquisition has to stay synchronous to be race free.
74
- */
75
- waitForInFlightAcquisition() {
76
- const key = this.getSessionKey();
77
- if (!inFlightAcquisitions.has(key))
78
- return undefined;
79
- return (async () => {
80
- let pending = inFlightAcquisitions.get(key);
81
- while (pending) {
82
- await pending.catch(() => undefined);
83
- pending = inFlightAcquisitions.get(key);
84
- }
85
- })();
86
- }
87
- /** Runs a cold acquisition and registers it so concurrent callers wait for it instead of opening their own session. */
88
- async runColdAcquisition(acquire) {
89
- const key = this.getSessionKey();
90
- const acquisition = acquire();
91
- inFlightAcquisitions.set(key, acquisition);
92
- try {
93
- return await acquisition;
94
- }
95
- finally {
96
- if (inFlightAcquisitions.get(key) === acquisition)
97
- inFlightAcquisitions.delete(key);
98
- }
99
- }
100
- getSessionKey() {
101
- // Options that change the cached value are part of the key; JSON avoids '|' collisions between fields.
102
- const { cookieName, ttlMs } = this.options;
103
- const staticHeaders = Object.entries(this.config.flexplmConnect?.staticHeaders ?? {})
104
- .map(([name, value]) => [name.toLowerCase(), value])
105
- .sort(([nameA, valueA], [nameB, valueB]) => {
106
- if (nameA !== nameB)
107
- return nameA < nameB ? -1 : 1;
108
- return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
109
- });
110
- const authenticationAndRoutingDigest = (0, crypto_1.createHmac)('sha256', SESSION_CACHE_DIGEST_KEY)
111
- .update(JSON.stringify([this.config.userName(), staticHeaders]))
112
- .digest('hex');
113
- return JSON.stringify([
114
- this.config.apiHost, this.config.urlContext, this.config.plmEnviornment, cookieName, ttlMs, authenticationAndRoutingDigest,
115
- ]);
116
- }
117
- getCachedSession() {
118
- if (!this.options.enabled)
119
- return undefined;
120
- const key = this.getSessionKey();
121
- const cachedSessValue = sessionCache.get(key);
122
- if (!cachedSessValue)
123
- return undefined;
124
- if (cachedSessValue.expiresAt <= Date.now()) {
125
- sessionCache.delete(key);
126
- return undefined;
127
- }
128
- return cachedSessValue.cookie;
129
- }
130
- /**
131
- * Compare-and-set: stores `cookie` only if the cache still holds the cookie this request was sent with
132
- * (`sentCookie`, undefined for a cold acquisition = nothing cached). A late response must not roll back or
133
- * resurrect a session that a concurrent event already replaced or cleared.
134
- * @returns true if the cache was updated
135
- */
136
- storeSession(cookie, sentCookie) {
137
- const key = this.getSessionKey();
138
- if (sessionCache.get(key)?.cookie !== sentCookie)
139
- return false;
140
- // sliding expiry: Windchill's session timeout is also sliding
141
- // delete first so a refreshed entry moves to the newest position (oldest-first eviction then acts as LRU)
142
- sessionCache.delete(key);
143
- pruneSessionCache();
144
- sessionCache.set(key, { cookie, expiresAt: Date.now() + this.options.ttlMs });
145
- return true;
146
- }
147
- clearSession() {
148
- sessionCache.delete(this.getSessionKey());
149
- }
150
- /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
151
- extractSessionCookie(response) {
152
- const respHeader = response?.headers;
153
- if (!respHeader)
154
- return undefined;
155
- let raw = [];
156
- if (typeof respHeader.getSetCookie === 'function') {
157
- raw = respHeader.getSetCookie();
158
- }
159
- else if (typeof respHeader.get === 'function' && respHeader.get('set-cookie')) {
160
- raw = [respHeader.get('set-cookie')];
161
- }
162
- const name = this.options.cookieName;
163
- const regexFilter = new RegExp('(?:^|[,\\s])' + escapeRegExp(name) + '=([^;,\\s]+)', 'g');
164
- let jSession;
165
- for (const line of raw) {
166
- let cookieNameMatcher = regexFilter.exec(line);
167
- while (cookieNameMatcher !== null) {
168
- jSession = cookieNameMatcher[1];
169
- cookieNameMatcher = regexFilter.exec(line);
170
- }
171
- }
172
- return jSession;
173
- }
174
- /**
175
- * Builds the Cookie header value from the configured static Cookie header and the managed session cookie.
176
- * With session reuse enabled only the managed cookie is replaced/removed; every other static cookie pair is kept.
177
- * With session reuse disabled the static value is returned unchanged.
178
- */
179
- buildCookieHeader(staticCookie, sessionCookie) {
180
- if (!this.options.enabled)
181
- return staticCookie || undefined;
182
- const name = this.options.cookieName;
183
- const pairs = (staticCookie ?? '')
184
- .split(';')
185
- .map(pair => pair.trim())
186
- .filter(pair => pair && pair.split('=')[0].trim() !== name);
187
- if (sessionCookie)
188
- pairs.push(`${name}=${sessionCookie}`);
189
- return pairs.length ? pairs.join('; ') : undefined;
190
- }
191
- }
192
- exports.FlexPLMSessionManager = FlexPLMSessionManager;
@@ -1 +0,0 @@
1
- export {};
@@ -1,70 +0,0 @@
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 = (user = 'mgr-user-' + (++userCounter), staticHeaders) => {
6
- const config = {
7
- apiHost: 'https://flexplm.example.com',
8
- urlContext: '/FlexPLM',
9
- vibeEventEndpoint: '/api/events',
10
- csrfEndpoint: '/csrf',
11
- plmEnviornment: 'test',
12
- userName: () => user,
13
- password: () => 'pass',
14
- itemPreDevelopmentLifecycleStages: [],
15
- flexplmConnect: { staticHeaders, sessionReuse: { enabled: true } },
16
- };
17
- return new flexplm_session_manager_1.FlexPLMSessionManager(config);
18
- };
19
- describe('FlexPLMSessionManager.storeSession (compare-and-set)', () => {
20
- it('should store a cold acquisition when nothing is cached', () => {
21
- const m = newManager();
22
- expect(m.storeSession('a', undefined)).toBe(true);
23
- expect(m.getCachedSession()).toBe('a');
24
- });
25
- it('should not let a cold acquisition overwrite an entry that appeared meanwhile', () => {
26
- const m = newManager();
27
- m.storeSession('a', undefined);
28
- expect(m.storeSession('b', undefined)).toBe(false);
29
- expect(m.getCachedSession()).toBe('a');
30
- });
31
- it('should replace or refresh the entry while it still holds the cookie that was sent', () => {
32
- const m = newManager();
33
- m.storeSession('old', undefined);
34
- expect(m.storeSession('new', 'old')).toBe(true);
35
- expect(m.getCachedSession()).toBe('new');
36
- expect(m.storeSession('new', 'new')).toBe(true); // sliding refresh with the same cookie
37
- });
38
- it('should ignore a late response that was sent with an older cookie', () => {
39
- const m = newManager();
40
- m.storeSession('old', undefined);
41
- m.storeSession('new', 'old');
42
- expect(m.storeSession('old', 'old')).toBe(false);
43
- expect(m.getCachedSession()).toBe('new');
44
- });
45
- it('should not resurrect a session that was cleared meanwhile', () => {
46
- const m = newManager();
47
- m.storeSession('old', undefined);
48
- m.clearSession();
49
- expect(m.storeSession('old', 'old')).toBe(false);
50
- expect(m.getCachedSession()).toBeUndefined();
51
- });
52
- it('should isolate cached sessions when static authorization headers differ', () => {
53
- const firstManager = newManager('shared-user', { Authorization: 'Basic first', PLM_ENV: 'test' });
54
- const secondManager = newManager('shared-user', { Authorization: 'Basic second', PLM_ENV: 'test' });
55
- firstManager.storeSession('first-session', undefined);
56
- expect(secondManager.getCachedSession()).toBeUndefined();
57
- });
58
- it('should isolate cached sessions when static routing headers differ', () => {
59
- const firstManager = newManager('shared-user', { 'X-Route': 'route-a' });
60
- const secondManager = newManager('shared-user', { 'X-Route': 'route-b' });
61
- firstManager.storeSession('first-session', undefined);
62
- expect(secondManager.getCachedSession()).toBeUndefined();
63
- });
64
- it('should reuse cached sessions for equivalent static headers regardless of header order or casing', () => {
65
- const firstManager = newManager('shared-user', { 'Authorization': 'Basic same', 'X-Route': 'route-a' });
66
- const secondManager = newManager('shared-user', { 'x-route': 'route-a', 'authorization': 'Basic same' });
67
- firstManager.storeSession('shared-session', undefined);
68
- expect(secondManager.getCachedSession()).toBe('shared-session');
69
- });
70
- });