@contrail/flexplm 1.7.7-alpha.279b642 → 1.7.7-alpha.3e12cec

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.
@@ -3,6 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FlexPLMConnect = void 0;
4
4
  const app_framework_1 = require("@contrail/app-framework");
5
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
+ };
6
15
  class FlexPLMConnect {
7
16
  constructor(_config, endPoint = undefined, payloadAsArray = undefined) {
8
17
  this.vibeEventEndpoint = '';
@@ -48,17 +57,21 @@ class FlexPLMConnect {
48
57
  Object.assign(csrfOptions.headers, this.staticHeaders);
49
58
  }
50
59
  // Added for Session reuse - ET-390 - Starts
51
- const cookie = this.sessions.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]);
52
66
  if (cookie) {
53
- csrfOptions.headers['Cookie'] = cookie;
67
+ headers['Cookie'] = cookie;
54
68
  }
55
69
  // Added for Session reuse - ET-390 - Ends
56
70
  if (app_framework_1.Logger.isInfoOn()) {
57
71
  const logOptions = JSON.parse(JSON.stringify(csrfOptions));
58
72
  logOptions['headers']['Authorization'] = logOptions?.headers?.Authorization.substring(0, 9);
59
73
  // Added for Session reuse - ET-390 - Starts
60
- if (logOptions.headers['Cookie'])
61
- logOptions.headers['Cookie'] = '***masked***';
74
+ maskCookieHeaders(logOptions.headers);
62
75
  // Added for Session reuse - ET-390 - Ends
63
76
  console.info('csrfOptions: ' + JSON.stringify(logOptions));
64
77
  }
@@ -127,7 +140,7 @@ class FlexPLMConnect {
127
140
  if (this.sessions.options.enabled) {
128
141
  sessionCookie = this.sessions.extractSessionCookie(response) ?? sentCookie;
129
142
  if (sessionCookie)
130
- this.sessions.storeSession(sessionCookie);
143
+ this.sessions.storeSession(sessionCookie, sentCookie);
131
144
  }
132
145
  // Added for Session reuse - ET-390 - Ends
133
146
  try {
@@ -163,8 +176,7 @@ class FlexPLMConnect {
163
176
  const logOptions = JSON.parse(JSON.stringify(xferOptions));
164
177
  if (logOptions?.headers?.Authorization)
165
178
  logOptions.headers.Authorization = logOptions?.headers?.Authorization.substring(0, 9);
166
- if (logOptions?.headers?.Cookie)
167
- logOptions.headers.Cookie = '***masked***';
179
+ maskCookieHeaders(logOptions?.headers);
168
180
  if (csrf?.nonce_key)
169
181
  logOptions.headers[csrf.nonce_key] = csrf.nonce ? `***masked(length:${csrf.nonce.length})***` : '***masked(empty)***';
170
182
  console.info('csrfOptions: ' + JSON.stringify(logOptions));
@@ -264,7 +276,7 @@ class FlexPLMConnect {
264
276
  if (!(sent && this.sessions.options.retryStatuses.includes(res.status))) {
265
277
  const cookie = this.sessions.extractSessionCookie(res) ?? sent;
266
278
  if (cookie)
267
- this.sessions.storeSession(cookie);
279
+ this.sessions.storeSession(cookie, sent);
268
280
  }
269
281
  return { response: res, sent };
270
282
  };
@@ -354,6 +354,60 @@ describe('FlexPLMConnect session reuse', () => {
354
354
  isInfoOn.mockRestore();
355
355
  info.mockRestore();
356
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
+ });
357
411
  describe('option normalisation', () => {
358
412
  const rejectedOnceThenOk = (u, opts) => {
359
413
  fetchMock()
@@ -521,6 +575,41 @@ describe('FlexPLMConnect session reuse', () => {
521
575
  describe('concurrent session acquisition', () => {
522
576
  const flush = () => new Promise(r => setImmediate(r));
523
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
+ });
524
613
  it('should share one session across concurrent events on a cold cache', async () => {
525
614
  const u = uniqueUser();
526
615
  let release;
@@ -21,9 +21,20 @@ export declare class FlexPLMSessionManager {
21
21
  runColdAcquisition<T>(acquire: () => Promise<T>): Promise<T>;
22
22
  private getSessionKey;
23
23
  getCachedSession(): string | undefined;
24
- storeSession(cookie: string): void;
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;
25
31
  clearSession(): void;
26
32
  /** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
27
33
  extractSessionCookie(response: Response): string | undefined;
28
- cookieHeader(sessionCookie?: string): 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;
29
40
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FlexPLMSessionManager = exports.SessionRejectedError = void 0;
4
+ const crypto_1 = require("crypto");
4
5
  // Module-level on purpose: a new FlexPLMConnect (and session manager) is constructed for every event.
5
6
  const sessionCache = new Map();
6
7
  // Upper bound so users/option combinations that are never requested again cannot accumulate cookies forever.
@@ -18,6 +19,7 @@ const pruneSessionCache = () => {
18
19
  };
19
20
  // In-flight cold session acquisitions per key, so concurrent events share one FlexPLM session instead of each creating their own.
20
21
  const inFlightAcquisitions = new Map();
22
+ const SESSION_CACHE_DIGEST_KEY = (0, crypto_1.randomBytes)(32);
21
23
  const DEFAULT_SESSION_COOKIE_NAME = 'JSESSIONID';
22
24
  const DEFAULT_SESSION_TTL_SECONDS = 900;
23
25
  const DEFAULT_RETRY_STATUSES = [401, 403];
@@ -98,8 +100,18 @@ class FlexPLMSessionManager {
98
100
  getSessionKey() {
99
101
  // Options that change the cached value are part of the key; JSON avoids '|' collisions between fields.
100
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(), this.config.password(), staticHeaders]))
112
+ .digest('hex');
101
113
  return JSON.stringify([
102
- this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment, cookieName, ttlMs,
114
+ this.config.apiHost, this.config.urlContext, this.config.plmEnviornment, cookieName, ttlMs, authenticationAndRoutingDigest,
103
115
  ]);
104
116
  }
105
117
  getCachedSession() {
@@ -115,13 +127,22 @@ class FlexPLMSessionManager {
115
127
  }
116
128
  return cachedSessValue.cookie;
117
129
  }
118
- storeSession(cookie) {
119
- // sliding expiry: Windchill's session timeout is also sliding
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) {
120
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
121
141
  // delete first so a refreshed entry moves to the newest position (oldest-first eviction then acts as LRU)
122
142
  sessionCache.delete(key);
123
143
  pruneSessionCache();
124
144
  sessionCache.set(key, { cookie, expiresAt: Date.now() + this.options.ttlMs });
145
+ return true;
125
146
  }
126
147
  clearSession() {
127
148
  sessionCache.delete(this.getSessionKey());
@@ -150,8 +171,22 @@ class FlexPLMSessionManager {
150
171
  }
151
172
  return jSession;
152
173
  }
153
- cookieHeader(sessionCookie) {
154
- return sessionCookie ? `${this.options.cookieName}=${sessionCookie}` : undefined;
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;
155
190
  }
156
191
  }
157
192
  exports.FlexPLMSessionManager = FlexPLMSessionManager;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,70 @@
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/flexplm",
3
- "version": "1.7.7-alpha.279b642",
3
+ "version": "1.7.7-alpha.3e12cec",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",