@contrail/flexplm 1.7.7-alpha.89ddfa2 → 1.7.7-alpha.99f62a7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/interfaces/interfaces.d.ts +13 -1
- package/lib/util/flexplm-connect.d.ts +5 -13
- package/lib/util/flexplm-connect.js +94 -114
- package/lib/util/flexplm-connect.spec.js +377 -27
- package/lib/util/flexplm-session-manager.d.ts +40 -0
- package/lib/util/flexplm-session-manager.js +192 -0
- package/lib/util/flexplm-session-manager.spec.d.ts +1 -0
- package/lib/util/flexplm-session-manager.spec.js +70 -0
- package/package.json +1 -1
|
@@ -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?:
|
|
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
|
-
|
|
9
|
-
private
|
|
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):
|
|
18
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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,87 +29,18 @@ class FlexPLMConnect {
|
|
|
21
29
|
this.staticHeaders = this.config?.flexplmConnect?.staticHeaders;
|
|
22
30
|
}
|
|
23
31
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
console.info('sessReuseBool value is --> ' + sessReuseBool);
|
|
31
|
-
console.info('enabled value is --> ' + sessReuseBool?.enabled);
|
|
32
|
-
return {
|
|
33
|
-
enabled: sessReuseBool?.enabled === true || String(sessReuseBool?.enabled).toLowerCase() === 'true',
|
|
34
|
-
ttlMs: (sessReuseBool?.ttlSeconds ?? 900) * 1000,
|
|
35
|
-
cookieName: sessReuseBool?.cookieName ?? 'JSESSIONID',
|
|
36
|
-
retryStatuses: (sessReuseBool?.retryStatuses ?? [401, 403]) as number[],
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
*/
|
|
40
|
-
get sessionOptions() {
|
|
41
|
-
const cfg = this.config ?? {};
|
|
42
|
-
const sessReuseEntries = (name) => cfg[`flexplmConnect.sessionReuse.${name}`];
|
|
43
|
-
const enabled = sessReuseEntries('enabled');
|
|
44
|
-
return {
|
|
45
|
-
enabled: enabled === true || String(enabled).toLowerCase() === 'true',
|
|
46
|
-
ttlMs: (sessReuseEntries('ttlSeconds') ?? 900) * 1000,
|
|
47
|
-
cookieName: sessReuseEntries('cookieName') ?? 'JSESSIONID',
|
|
48
|
-
retryStatuses: (sessReuseEntries('retryStatuses') ?? [401, 403]),
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
getSessionKey() {
|
|
52
|
-
return [this.config.apiHost, this.config.urlContext, this.config.userName(), this.config.plmEnviornment].join('|');
|
|
53
|
-
}
|
|
54
|
-
getCachedSession() {
|
|
55
|
-
if (!this.sessionOptions.enabled)
|
|
56
|
-
return undefined;
|
|
57
|
-
const key = this.getSessionKey();
|
|
58
|
-
const cachedSessValue = sessionCache.get(key);
|
|
59
|
-
if (!cachedSessValue)
|
|
60
|
-
return undefined;
|
|
61
|
-
if (cachedSessValue.expiresAt <= Date.now()) {
|
|
62
|
-
sessionCache.delete(key);
|
|
63
|
-
return undefined;
|
|
64
|
-
}
|
|
65
|
-
return cachedSessValue.cookie;
|
|
66
|
-
}
|
|
67
|
-
storeSession(cookie) {
|
|
68
|
-
// sliding expiry: Windchill's session timeout is also sliding
|
|
69
|
-
sessionCache.set(this.getSessionKey(), { cookie, expiresAt: Date.now() + this.sessionOptions.ttlMs });
|
|
70
|
-
}
|
|
71
|
-
clearSession() {
|
|
72
|
-
sessionCache.delete(this.getSessionKey());
|
|
73
|
-
}
|
|
74
|
-
/** Returns the session cookie value Windchill issued on this response, if any (last one wins). */
|
|
75
|
-
extractSessionCookie(response) {
|
|
76
|
-
const respHeader = response?.headers;
|
|
77
|
-
if (!respHeader)
|
|
78
|
-
return undefined;
|
|
79
|
-
let raw = [];
|
|
80
|
-
if (typeof respHeader.getSetCookie === 'function') {
|
|
81
|
-
raw = respHeader.getSetCookie();
|
|
82
|
-
}
|
|
83
|
-
else if (typeof respHeader.get === 'function' && respHeader.get('set-cookie')) {
|
|
84
|
-
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();
|
|
85
38
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
let jSession;
|
|
89
|
-
for (const line of raw) {
|
|
90
|
-
let cookieNameMatcher = regexFilter.exec(line);
|
|
91
|
-
while (cookieNameMatcher !== null) {
|
|
92
|
-
jSession = cookieNameMatcher[1];
|
|
93
|
-
cookieNameMatcher = regexFilter.exec(line);
|
|
94
|
-
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
// best effort: nothing to do if the body is already consumed or locked
|
|
95
41
|
}
|
|
96
|
-
return jSession;
|
|
97
|
-
}
|
|
98
|
-
cookieHeader(sessionCookie) {
|
|
99
|
-
return sessionCookie ? `${this.sessionOptions.cookieName}=${sessionCookie}` : undefined;
|
|
100
42
|
}
|
|
101
43
|
/////////////////////////////////////////////////////////////////////////////
|
|
102
|
-
///////// ET-390 - Session reuse: end
|
|
103
|
-
/////////////////////////////////////////////////////////////////////////////
|
|
104
|
-
/////////////////////////////////////////////////////////////////////////////
|
|
105
44
|
///////// Custom getRequestOptions: start
|
|
106
45
|
/////////////////////////////////////////////////////////////////////////////
|
|
107
46
|
// ET-390 - adding a new parameter sessionCookie to the getRequestOptions function
|
|
@@ -118,17 +57,21 @@ class FlexPLMConnect {
|
|
|
118
57
|
Object.assign(csrfOptions.headers, this.staticHeaders);
|
|
119
58
|
}
|
|
120
59
|
// Added for Session reuse - ET-390 - Starts
|
|
121
|
-
|
|
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]);
|
|
122
66
|
if (cookie) {
|
|
123
|
-
|
|
67
|
+
headers['Cookie'] = cookie;
|
|
124
68
|
}
|
|
125
69
|
// Added for Session reuse - ET-390 - Ends
|
|
126
70
|
if (app_framework_1.Logger.isInfoOn()) {
|
|
127
71
|
const logOptions = JSON.parse(JSON.stringify(csrfOptions));
|
|
128
72
|
logOptions['headers']['Authorization'] = logOptions?.headers?.Authorization.substring(0, 9);
|
|
129
73
|
// Added for Session reuse - ET-390 - Starts
|
|
130
|
-
|
|
131
|
-
logOptions.headers['Cookie'] = '***masked***';
|
|
74
|
+
maskCookieHeaders(logOptions.headers);
|
|
132
75
|
// Added for Session reuse - ET-390 - Ends
|
|
133
76
|
console.info('csrfOptions: ' + JSON.stringify(logOptions));
|
|
134
77
|
}
|
|
@@ -139,26 +82,48 @@ class FlexPLMConnect {
|
|
|
139
82
|
/////////////////////////////////////////////////////////////////////////////
|
|
140
83
|
// ET-390 - adding a new parameter getNewSession to the getCSRF function
|
|
141
84
|
// Whenever a getCSRF is called without getNewSession, the default value of false will be used
|
|
142
|
-
|
|
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) {
|
|
143
115
|
const urlContext = this.config.urlContext;
|
|
144
116
|
const csrfEndpoint = this.config.csrfEndpoint;
|
|
145
117
|
const csrfURL = this.config.apiHost + urlContext + csrfEndpoint;
|
|
146
118
|
console.info('csrfURL: ' + csrfURL);
|
|
147
|
-
// Added for Session reuse - ET-390 - Starts
|
|
148
|
-
if (getNewSession)
|
|
149
|
-
this.clearSession();
|
|
150
|
-
let sentCookie = this.getCachedSession();
|
|
151
119
|
// getRequestOptions will be called with additional sentCookie parameter
|
|
152
120
|
const csrfOptions = this.getRequestOptions('GET', sentCookie);
|
|
153
|
-
|
|
154
|
-
let response = await fetch(csrfURL, csrfOptions);
|
|
121
|
+
const response = await fetch(csrfURL, csrfOptions);
|
|
155
122
|
// Added for Session reuse - ET-390 - Starts
|
|
156
|
-
|
|
157
|
-
if (sentCookie && this.sessionOptions.retryStatuses.includes(response.status)) {
|
|
123
|
+
if (sentCookie && this.sessions.options.retryStatuses.includes(response.status)) {
|
|
158
124
|
console.warn('Cached FlexPLM session rejected on csrf call (status ' + response.status + '). Retrying with a new session.');
|
|
159
|
-
this.
|
|
160
|
-
|
|
161
|
-
response = await fetch(csrfURL, this.getRequestOptions('GET'));
|
|
125
|
+
await this.discardBody(response);
|
|
126
|
+
throw new flexplm_session_manager_1.SessionRejectedError();
|
|
162
127
|
}
|
|
163
128
|
// Added for Session reuse - ET-390 - Ends
|
|
164
129
|
if (response.status >= 300) {
|
|
@@ -172,10 +137,10 @@ class FlexPLMConnect {
|
|
|
172
137
|
// Added for Session reuse - ET-390 - Starts
|
|
173
138
|
// If the cookie we sent was invalid, Windchill answers with a fresh Set-Cookie --> replace the cache.
|
|
174
139
|
let sessionCookie = sentCookie;
|
|
175
|
-
if (this.
|
|
176
|
-
sessionCookie = this.extractSessionCookie(response) ?? sentCookie;
|
|
140
|
+
if (this.sessions.options.enabled) {
|
|
141
|
+
sessionCookie = this.sessions.extractSessionCookie(response) ?? sentCookie;
|
|
177
142
|
if (sessionCookie)
|
|
178
|
-
this.storeSession(sessionCookie);
|
|
143
|
+
this.sessions.storeSession(sessionCookie, sentCookie);
|
|
179
144
|
}
|
|
180
145
|
// Added for Session reuse - ET-390 - Ends
|
|
181
146
|
try {
|
|
@@ -211,8 +176,7 @@ class FlexPLMConnect {
|
|
|
211
176
|
const logOptions = JSON.parse(JSON.stringify(xferOptions));
|
|
212
177
|
if (logOptions?.headers?.Authorization)
|
|
213
178
|
logOptions.headers.Authorization = logOptions?.headers?.Authorization.substring(0, 9);
|
|
214
|
-
|
|
215
|
-
logOptions.headers.Cookie = '***masked***';
|
|
179
|
+
maskCookieHeaders(logOptions?.headers);
|
|
216
180
|
if (csrf?.nonce_key)
|
|
217
181
|
logOptions.headers[csrf.nonce_key] = csrf.nonce ? `***masked(length:${csrf.nonce.length})***` : '***masked(empty)***';
|
|
218
182
|
console.info('csrfOptions: ' + JSON.stringify(logOptions));
|
|
@@ -239,9 +203,10 @@ class FlexPLMConnect {
|
|
|
239
203
|
/// Added for Session reuse - ET-390 - Starts
|
|
240
204
|
// const response = await this.sendRequest(csrf, payload);
|
|
241
205
|
let response = await this.sendRequest(csrf, payload);
|
|
242
|
-
if (csrf.sessionCookie && this.
|
|
206
|
+
if (csrf.sessionCookie && this.sessions.options.retryStatuses.includes(response.status)) {
|
|
243
207
|
console.warn('FlexPLM rejected session/nonce (status ' + response.status + '). Getting a new session and retrying once.');
|
|
244
|
-
|
|
208
|
+
await this.discardBody(response);
|
|
209
|
+
csrf = await this.getCSRF(true, csrf.sessionCookie);
|
|
245
210
|
response = await this.sendRequest(csrf, payload);
|
|
246
211
|
}
|
|
247
212
|
// Added for Session reuse - ET-390 - Ends
|
|
@@ -298,17 +263,32 @@ class FlexPLMConnect {
|
|
|
298
263
|
const path = urlPath || ('/servlet/rest' + this.vibeEventEndpoint);
|
|
299
264
|
const vibeEventsURL = this.config.apiHost + urlContext + path;
|
|
300
265
|
// Added for Session reuse - ET-390 - Starts
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
this.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
this.
|
|
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;
|
|
312
292
|
}
|
|
313
293
|
// Added for Session reuse - ET-390 - Ends
|
|
314
294
|
if (response.status >= 300) {
|
|
@@ -39,12 +39,8 @@ const resp = (status, setCookie = [], json = csrfBody) => ({
|
|
|
39
39
|
});
|
|
40
40
|
let userCounter = 0;
|
|
41
41
|
const uniqueUser = () => 'sess-user-' + (++userCounter);
|
|
42
|
-
//
|
|
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
|
-
};
|
|
42
|
+
// Session options are nested: { flexplmConnect: { sessionReuse: { enabled, ... } } }
|
|
43
|
+
const sessionConfig = (user, sessionReuse = { enabled: true }) => createConfig({ userName: () => user, flexplmConnect: { sessionReuse } });
|
|
48
44
|
const fetchMock = () => global.fetch;
|
|
49
45
|
const callHeaders = (i) => fetchMock().mock.calls[i][1].headers;
|
|
50
46
|
const callMethod = (i) => fetchMock().mock.calls[i][1].method;
|
|
@@ -145,7 +141,7 @@ describe('FlexPLMConnect.sendRequest', () => {
|
|
|
145
141
|
// Added for Session reuse - ET-390 - Starts
|
|
146
142
|
describe('FlexPLMConnect session reuse', () => {
|
|
147
143
|
const payload = { id: '123' };
|
|
148
|
-
it('
|
|
144
|
+
it('should capture JSESSIONID from the CSRF GET and send it on the POST', async () => {
|
|
149
145
|
const u = uniqueUser();
|
|
150
146
|
fetchMock()
|
|
151
147
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/; HttpOnly']))
|
|
@@ -156,7 +152,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
156
152
|
expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
|
|
157
153
|
expect(callHeaders(1).CSRF_NONCE).toBe('n1');
|
|
158
154
|
});
|
|
159
|
-
it('
|
|
155
|
+
it('should reuse the cached session on the next event (new FlexPLMConnect instance)', async () => {
|
|
160
156
|
const u = uniqueUser();
|
|
161
157
|
fetchMock()
|
|
162
158
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1; Path=/'])) // event 1 GET
|
|
@@ -168,7 +164,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
168
164
|
expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1'); // GET reuses it
|
|
169
165
|
expect(callHeaders(3).Cookie).toBe('JSESSIONID=abc.tomcat1'); // POST reuses it
|
|
170
166
|
});
|
|
171
|
-
it('
|
|
167
|
+
it('should replace the cached session when Windchill answers the GET with a new Set-Cookie', async () => {
|
|
172
168
|
const u = uniqueUser();
|
|
173
169
|
fetchMock()
|
|
174
170
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1; Path=/']))
|
|
@@ -185,7 +181,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
185
181
|
expect(callHeaders(3).Cookie).toBe('JSESSIONID=new.tomcat1'); // POST uses the replacement
|
|
186
182
|
expect(callHeaders(4).Cookie).toBe('JSESSIONID=new.tomcat1'); // event 3 GET sends the replacement
|
|
187
183
|
});
|
|
188
|
-
it('
|
|
184
|
+
it('should retry once with a new session when the POST is rejected with 403', async () => {
|
|
189
185
|
const u = uniqueUser();
|
|
190
186
|
fetchMock()
|
|
191
187
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1; Path=/'])) // GET
|
|
@@ -199,7 +195,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
199
195
|
expect(result.status).toBe(200);
|
|
200
196
|
expect(result.data).toEqual({ ok: true });
|
|
201
197
|
});
|
|
202
|
-
it('also
|
|
198
|
+
it('should also retry on 401', async () => {
|
|
203
199
|
const u = uniqueUser();
|
|
204
200
|
fetchMock()
|
|
205
201
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
|
|
@@ -209,7 +205,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
209
205
|
await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
|
|
210
206
|
expect(fetchMock()).toHaveBeenCalledTimes(4);
|
|
211
207
|
});
|
|
212
|
-
it('
|
|
208
|
+
it('should not loop: a second 403 after the retry is surfaced as an error', async () => {
|
|
213
209
|
const u = uniqueUser();
|
|
214
210
|
fetchMock()
|
|
215
211
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1']))
|
|
@@ -220,7 +216,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
220
216
|
.rejects.toThrow('Error sending data to FlexPLM:status: 403');
|
|
221
217
|
expect(fetchMock()).toHaveBeenCalledTimes(4);
|
|
222
218
|
});
|
|
223
|
-
it('
|
|
219
|
+
it('should not retry on non-auth errors such as 500 (event may already be processed)', async () => {
|
|
224
220
|
const u = uniqueUser();
|
|
225
221
|
fetchMock()
|
|
226
222
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
|
|
@@ -229,7 +225,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
229
225
|
.rejects.toThrow('Error sending data to FlexPLM:status: 500');
|
|
230
226
|
expect(fetchMock()).toHaveBeenCalledTimes(2);
|
|
231
227
|
});
|
|
232
|
-
it('
|
|
228
|
+
it('should retry the csrf GET without a cookie when the cached session is rejected there', async () => {
|
|
233
229
|
const u = uniqueUser();
|
|
234
230
|
fetchMock()
|
|
235
231
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=old.tomcat1'])) // event 1 GET
|
|
@@ -243,7 +239,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
243
239
|
expect(callHeaders(3).Cookie).toBeUndefined();
|
|
244
240
|
expect(callHeaders(4).Cookie).toBe('JSESSIONID=new.tomcat1');
|
|
245
241
|
});
|
|
246
|
-
it('
|
|
242
|
+
it('should expire the cached session after ttlSeconds', async () => {
|
|
247
243
|
const u = uniqueUser();
|
|
248
244
|
const realNow = Date.now();
|
|
249
245
|
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(realNow);
|
|
@@ -260,7 +256,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
260
256
|
expect(callHeaders(3).Cookie).toBe('JSESSIONID=def.tomcat1');
|
|
261
257
|
nowSpy.mockRestore();
|
|
262
258
|
});
|
|
263
|
-
it('
|
|
259
|
+
it('should use the last JSESSIONID when several Set-Cookie lines are returned', async () => {
|
|
264
260
|
const u = uniqueUser();
|
|
265
261
|
fetchMock()
|
|
266
262
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=first.tomcat1; Path=/', 'OTHER=x; Path=/', 'JSESSIONID=second.tomcat1; Path=/']))
|
|
@@ -268,7 +264,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
268
264
|
await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
|
|
269
265
|
expect(callHeaders(1).Cookie).toBe('JSESSIONID=second.tomcat1');
|
|
270
266
|
});
|
|
271
|
-
it('
|
|
267
|
+
it('should fall back to headers.get("set-cookie") when getSetCookie is not available', async () => {
|
|
272
268
|
const u = uniqueUser();
|
|
273
269
|
const legacy = {
|
|
274
270
|
status: 200,
|
|
@@ -280,7 +276,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
280
276
|
await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u)).sendToFlexPLM(payload);
|
|
281
277
|
expect(callHeaders(1).Cookie).toBe('JSESSIONID=legacy.tomcat1');
|
|
282
278
|
});
|
|
283
|
-
it('
|
|
279
|
+
it('should honour a custom cookieName', async () => {
|
|
284
280
|
const u = uniqueUser();
|
|
285
281
|
fetchMock()
|
|
286
282
|
.mockResolvedValueOnce(resp(200, ['MYSESSION=zzz; Path=/', 'JSESSIONID=ignored; Path=/']))
|
|
@@ -288,7 +284,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
288
284
|
await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: true, cookieName: 'MYSESSION' })).sendToFlexPLM(payload);
|
|
289
285
|
expect(callHeaders(1).Cookie).toBe('MYSESSION=zzz');
|
|
290
286
|
});
|
|
291
|
-
it('
|
|
287
|
+
it('should accept enabled as the string "true"', async () => {
|
|
292
288
|
const u = uniqueUser();
|
|
293
289
|
fetchMock()
|
|
294
290
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
|
|
@@ -296,7 +292,39 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
296
292
|
await new flexplm_connect_1.FlexPLMConnect(sessionConfig(u, { enabled: 'true' })).sendToFlexPLM(payload);
|
|
297
293
|
expect(callHeaders(1).Cookie).toBe('JSESSIONID=abc.tomcat1');
|
|
298
294
|
});
|
|
299
|
-
it('
|
|
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 () => {
|
|
300
328
|
const a = uniqueUser();
|
|
301
329
|
const b = uniqueUser();
|
|
302
330
|
fetchMock()
|
|
@@ -309,7 +337,7 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
309
337
|
expect(callHeaders(2).Cookie).toBeUndefined(); // user b must not see user a's cookie
|
|
310
338
|
expect(callHeaders(3).Cookie).toBe('JSESSIONID=bbb.tomcat1');
|
|
311
339
|
});
|
|
312
|
-
it('never
|
|
340
|
+
it('should never log the cookie value', async () => {
|
|
313
341
|
const isInfoOn = jest.spyOn(app_framework_1.Logger, 'isInfoOn').mockReturnValue(true);
|
|
314
342
|
const info = jest.spyOn(console, 'info').mockImplementation();
|
|
315
343
|
const u = uniqueUser();
|
|
@@ -326,10 +354,317 @@ describe('FlexPLMConnect session reuse', () => {
|
|
|
326
354
|
isInfoOn.mockRestore();
|
|
327
355
|
info.mockRestore();
|
|
328
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
|
+
});
|
|
329
664
|
});
|
|
330
665
|
describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
|
|
331
666
|
const payload = { id: '123' };
|
|
332
|
-
it('
|
|
667
|
+
it('should send no Cookie header and make exactly one GET + one POST', async () => {
|
|
333
668
|
const u = uniqueUser();
|
|
334
669
|
fetchMock()
|
|
335
670
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1'])) // Set-Cookie present but must be ignored
|
|
@@ -343,7 +678,7 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
|
|
|
343
678
|
expect(callHeaders(i).Cookie).toBeUndefined();
|
|
344
679
|
}
|
|
345
680
|
});
|
|
346
|
-
it('
|
|
681
|
+
it('should not retry on 403 (behaves exactly as before)', async () => {
|
|
347
682
|
const u = uniqueUser();
|
|
348
683
|
fetchMock()
|
|
349
684
|
.mockResolvedValueOnce(resp(200))
|
|
@@ -352,7 +687,7 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
|
|
|
352
687
|
.rejects.toThrow('Error sending data to FlexPLM:status: 403');
|
|
353
688
|
expect(fetchMock()).toHaveBeenCalledTimes(2);
|
|
354
689
|
});
|
|
355
|
-
it('
|
|
690
|
+
it('should behave the same as not configured when enabled is false', async () => {
|
|
356
691
|
const u = uniqueUser();
|
|
357
692
|
fetchMock()
|
|
358
693
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
|
|
@@ -361,8 +696,8 @@ describe('FlexPLMConnect session reuse - feature disabled (default)', () => {
|
|
|
361
696
|
expect(callHeaders(1).Cookie).toBeUndefined();
|
|
362
697
|
});
|
|
363
698
|
});
|
|
364
|
-
describe('FlexPLMConnect.getRequest session reuse
|
|
365
|
-
it('
|
|
699
|
+
describe('FlexPLMConnect.getRequest session reuse', () => {
|
|
700
|
+
it('should send the cached cookie on image/GET requests', async () => {
|
|
366
701
|
const u = uniqueUser();
|
|
367
702
|
fetchMock()
|
|
368
703
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1'])) // csrf GET primes the cache
|
|
@@ -373,7 +708,7 @@ describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)'
|
|
|
373
708
|
await connect.getRequest();
|
|
374
709
|
expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
|
|
375
710
|
});
|
|
376
|
-
it('
|
|
711
|
+
it('should retry once without the cookie when the cached session is rejected on getRequest', async () => {
|
|
377
712
|
const u = uniqueUser();
|
|
378
713
|
fetchMock()
|
|
379
714
|
.mockResolvedValueOnce(resp(200, ['JSESSIONID=abc.tomcat1']))
|
|
@@ -386,5 +721,20 @@ describe('FlexPLMConnect.getRequest session reuse (only if you applied step 2g)'
|
|
|
386
721
|
expect(callHeaders(2).Cookie).toBe('JSESSIONID=abc.tomcat1');
|
|
387
722
|
expect(callHeaders(3).Cookie).toBeUndefined();
|
|
388
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
|
+
});
|
|
389
739
|
});
|
|
390
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,192 @@
|
|
|
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;
|
|
@@ -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
|
+
});
|