@elisra-devops/docgen-data-provider 1.142.0 → 1.143.0

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.
Files changed (42) hide show
  1. package/bin/helpers/requestContext.d.ts +15 -0
  2. package/bin/helpers/requestContext.js +39 -2
  3. package/bin/helpers/requestContext.js.map +1 -1
  4. package/bin/helpers/tfs.js +9 -2
  5. package/bin/helpers/tfs.js.map +1 -1
  6. package/bin/modules/GitDataProvider.js +1 -1
  7. package/bin/modules/GitDataProvider.js.map +1 -1
  8. package/bin/modules/JfrogDataProvider.js +1 -1
  9. package/bin/modules/JfrogDataProvider.js.map +1 -1
  10. package/bin/modules/PipelinesDataProvider.js +3 -3
  11. package/bin/modules/PipelinesDataProvider.js.map +1 -1
  12. package/bin/modules/ResultDataProvider.js +10 -10
  13. package/bin/modules/ResultDataProvider.js.map +1 -1
  14. package/bin/modules/TestDataProvider.js +3 -3
  15. package/bin/modules/TestDataProvider.js.map +1 -1
  16. package/bin/modules/TicketsDataProvider.js +11 -11
  17. package/bin/modules/TicketsDataProvider.js.map +1 -1
  18. package/bin/tests/helpers/requestContext.test.js +39 -0
  19. package/bin/tests/helpers/requestContext.test.js.map +1 -1
  20. package/bin/tests/helpers/tfs.test.js +21 -2
  21. package/bin/tests/helpers/tfs.test.js.map +1 -1
  22. package/bin/tests/modules/ResultDataProvider.test.js +1 -0
  23. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
  24. package/bin/tests/utils/logger.test.js +26 -0
  25. package/bin/tests/utils/logger.test.js.map +1 -1
  26. package/bin/utils/logger.d.ts +2 -0
  27. package/bin/utils/logger.js +48 -9
  28. package/bin/utils/logger.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/helpers/requestContext.ts +39 -3
  31. package/src/helpers/tfs.ts +11 -3
  32. package/src/modules/GitDataProvider.ts +2 -2
  33. package/src/modules/JfrogDataProvider.ts +2 -2
  34. package/src/modules/PipelinesDataProvider.ts +4 -4
  35. package/src/modules/ResultDataProvider.ts +11 -11
  36. package/src/modules/TestDataProvider.ts +4 -4
  37. package/src/modules/TicketsDataProvider.ts +12 -12
  38. package/src/tests/helpers/requestContext.test.ts +50 -0
  39. package/src/tests/helpers/tfs.test.ts +23 -2
  40. package/src/tests/modules/ResultDataProvider.test.ts +1 -0
  41. package/src/tests/utils/logger.test.ts +35 -1
  42. package/src/utils/logger.ts +42 -4
@@ -10,7 +10,7 @@ import { TestSteps } from '../models/tfs-data';
10
10
  import TestStepParserHelper from '../utils/testStepParserHelper';
11
11
 
12
12
  import logger from '../utils/logger';
13
- import { describeError } from '../helpers/requestContext';
13
+ import { describeError, logCaughtError } from '../helpers/requestContext';
14
14
  const pLimit = require('p-limit');
15
15
 
16
16
  type FallbackFetchOutcome = {
@@ -492,7 +492,7 @@ export default class TicketsDataProvider {
492
492
  break;
493
493
  }
494
494
  } catch (err: any) {
495
- logger.error('Error occurred during fetching shared queries:', describeError(err));
495
+ logCaughtError('Error occurred during fetching shared queries:', err);
496
496
  throw err;
497
497
  }
498
498
  }
@@ -528,7 +528,7 @@ export default class TicketsDataProvider {
528
528
  })
529
529
  );
530
530
  } catch (err: any) {
531
- logger.error('Error occurred during fetching fields by type:', describeError(err));
531
+ logCaughtError('Error occurred during fetching fields by type:', err);
532
532
  throw err;
533
533
  }
534
534
  }
@@ -559,7 +559,7 @@ export default class TicketsDataProvider {
559
559
  }
560
560
  return { types, states: Array.from(stateSet) };
561
561
  } catch (err: any) {
562
- logger.error('Error fetching work item type states:', describeError(err));
562
+ logCaughtError('Error fetching work item type states:', err);
563
563
  throw err;
564
564
  }
565
565
  }
@@ -1287,7 +1287,7 @@ export default class TicketsDataProvider {
1287
1287
  break;
1288
1288
  }
1289
1289
  } catch (err: any) {
1290
- logger.error(`Could not fetch query results for ${wiqlHref}:`, describeError(err));
1290
+ logCaughtError(`Could not fetch query results for ${wiqlHref}:`, err);
1291
1291
  }
1292
1292
  }
1293
1293
 
@@ -1663,7 +1663,7 @@ export default class TicketsDataProvider {
1663
1663
  }
1664
1664
  return [...workItemsResultMap.values()];
1665
1665
  } catch (error: any) {
1666
- logger.error('could not parse requested flat query:', describeError(error));
1666
+ logCaughtError('could not parse requested flat query:', error);
1667
1667
  }
1668
1668
  }
1669
1669
 
@@ -3563,7 +3563,7 @@ export default class TicketsDataProvider {
3563
3563
 
3564
3564
  item.fields = { ...parsedFields };
3565
3565
  } catch (err: any) {
3566
- logger.error('Cannot filter columns:', describeError(err));
3566
+ logCaughtError('Cannot filter columns:', err);
3567
3567
  throw err;
3568
3568
  }
3569
3569
  }
@@ -3618,7 +3618,7 @@ export default class TicketsDataProvider {
3618
3618
 
3619
3619
  return workItemTypesWithIcons;
3620
3620
  } catch (err: any) {
3621
- logger.error('Error occurred during fetching work item types:', describeError(err));
3621
+ logCaughtError('Error occurred during fetching work item types:', err);
3622
3622
  throw err;
3623
3623
  }
3624
3624
  }
@@ -3793,7 +3793,7 @@ export default class TicketsDataProvider {
3793
3793
  totalCount: workItemIds.length,
3794
3794
  };
3795
3795
  } catch (err: any) {
3796
- logger.error('Could not fetch categorized requirements:', describeError(err));
3796
+ logCaughtError('Could not fetch categorized requirements:', err);
3797
3797
  throw err;
3798
3798
  }
3799
3799
  }
@@ -3888,7 +3888,7 @@ export default class TicketsDataProvider {
3888
3888
  const relations = qr?.workItemRelations || [];
3889
3889
  result['req-test'] = await resolveQuerySides(cols, relations, true);
3890
3890
  } catch (err: any) {
3891
- logger.error('GetTraceColumnsByType[req-test] failed:', describeError(err));
3891
+ logCaughtError('GetTraceColumnsByType[req-test] failed:', err);
3892
3892
  result['req-test'] = { Requirement: [], 'Test Case': [] };
3893
3893
  }
3894
3894
  }
@@ -3900,14 +3900,14 @@ export default class TicketsDataProvider {
3900
3900
  const relations = qr?.workItemRelations || [];
3901
3901
  result['test-req'] = await resolveQuerySides(cols, relations, false);
3902
3902
  } catch (err: any) {
3903
- logger.error('GetTraceColumnsByType[test-req] failed:', describeError(err));
3903
+ logCaughtError('GetTraceColumnsByType[test-req] failed:', err);
3904
3904
  result['test-req'] = { Requirement: [], 'Test Case': [] };
3905
3905
  }
3906
3906
  }
3907
3907
 
3908
3908
  return result;
3909
3909
  } catch (err: any) {
3910
- logger.error('GetTraceColumnsByType failed:', describeError(err));
3910
+ logCaughtError('GetTraceColumnsByType failed:', err);
3911
3911
  return {};
3912
3912
  }
3913
3913
  }
@@ -1,3 +1,4 @@
1
+ import logger from '../../utils/logger';
1
2
  import {
2
3
  sanitizeUrl,
3
4
  summarizeBody,
@@ -5,6 +6,9 @@ import {
5
6
  buildAdoRequestContext,
6
7
  annotateAdoError,
7
8
  maskWiqlLiterals,
9
+ templatePath,
10
+ describeError,
11
+ logCaughtError,
8
12
  } from '../../helpers/requestContext';
9
13
 
10
14
  describe('maskWiqlLiterals', () => {
@@ -200,3 +204,49 @@ describe('annotateAdoError', () => {
200
204
  expect(() => annotateAdoError(undefined as any, { url: 'u' })).not.toThrow();
201
205
  });
202
206
  });
207
+
208
+ describe('templatePath', () => {
209
+ it('drops origin and query and collapses numeric / guid segments', () => {
210
+ expect(templatePath('https://dev.azure.com/org/p/_apis/wit/workitems/123?api-version=7')).toBe(
211
+ '/org/p/_apis/wit/workitems/{id}'
212
+ );
213
+ expect(templatePath('https://h/a/3f2504e0-4f89-11d3-9a0c-0305e82c3301/b')).toBe('/a/{id}/b');
214
+ });
215
+ it('copes with a relative or missing url', () => {
216
+ expect(templatePath('/rel/42?x=1')).toBe('/rel/{id}');
217
+ expect(templatePath(undefined)).toBe('(unknown url)');
218
+ });
219
+ });
220
+
221
+ describe('describeError', () => {
222
+ it('carries the original message separately so the dashboard err.message is not the combined line', () => {
223
+ expect(describeError(new Error('boom'))).toMatchObject({ message: 'boom', errMessage: 'boom' });
224
+ });
225
+ });
226
+
227
+ describe('logCaughtError', () => {
228
+ beforeEach(() => {
229
+ jest.spyOn(logger, 'warn').mockImplementation((() => logger) as any);
230
+ jest.spyOn(logger, 'error').mockImplementation((() => logger) as any);
231
+ });
232
+ afterEach(() => jest.restoreAllMocks());
233
+
234
+ it('logs at warn when TFSServices already reported the error', () => {
235
+ const err = annotateAdoError(new Error('x'), { method: 'GET', url: 'https://h/y', reported: true });
236
+ logCaughtError('Could not fetch Y:', err);
237
+ expect(logger.warn).toHaveBeenCalledWith('Could not fetch Y:', expect.objectContaining({ message: 'x' }));
238
+ expect(logger.error).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it('stays at error when the error was annotated but not reported (printError=false)', () => {
242
+ const err = annotateAdoError(new Error('x'), { method: 'GET', url: 'https://h/y', reported: false });
243
+ logCaughtError('Could not fetch Y:', err);
244
+ expect(logger.error).toHaveBeenCalledTimes(1);
245
+ expect(logger.warn).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it('stays at error for a non-ADO error', () => {
249
+ logCaughtError('Could not fetch Y:', new Error('plain'));
250
+ expect(logger.error).toHaveBeenCalledTimes(1);
251
+ });
252
+ });
@@ -1115,6 +1115,7 @@ describe('TFSServices request-context annotation', () => {
1115
1115
  url: 'https://dev.azure.com/org/_apis/wit/queries/q1',
1116
1116
  status: 404,
1117
1117
  attempt: 1,
1118
+ reported: false,
1118
1119
  responseExcerpt: 'TF401232: Work item 5 does not exist',
1119
1120
  });
1120
1121
  expect(logger.error).not.toHaveBeenCalled();
@@ -1127,13 +1128,33 @@ describe('TFSServices request-context annotation', () => {
1127
1128
 
1128
1129
  expect(logger.error).toHaveBeenCalledTimes(1);
1129
1130
  expect(logger.error).toHaveBeenCalledWith(
1130
- 'ADO request failed',
1131
+ 'ADO request failed: GET /org/_apis/x -> 404',
1131
1132
  expect.objectContaining({
1132
- adoRequest: expect.objectContaining({ method: 'GET', url: 'https://dev.azure.com/org/_apis/x', status: 404 }),
1133
+ adoRequest: expect.objectContaining({
1134
+ method: 'GET',
1135
+ url: 'https://dev.azure.com/org/_apis/x',
1136
+ status: 404,
1137
+ reported: true,
1138
+ }),
1133
1139
  })
1134
1140
  );
1135
1141
  });
1136
1142
 
1143
+ it('puts status and templated path in the message so 401 and 404 group separately', async () => {
1144
+ const e401: any = new Error('Request failed with status code 401');
1145
+ e401.response = { status: 401, data: {} };
1146
+ mockAxiosInstance.request.mockRejectedValueOnce(e401);
1147
+ await TFSServices.getItemContent('https://dev.azure.com/org/p/_apis/wit/workitems/123', 'pat').catch(() => undefined);
1148
+ mockAxiosInstance.request.mockRejectedValueOnce(notFound());
1149
+ await TFSServices.getItemContent('https://dev.azure.com/org/p/_apis/wit/workitems/456', 'pat').catch(() => undefined);
1150
+
1151
+ const messages = (logger.error as jest.Mock).mock.calls.map((c) => c[0]);
1152
+ expect(messages).toEqual([
1153
+ 'ADO request failed: GET /org/p/_apis/wit/workitems/{id} -> 401',
1154
+ 'ADO request failed: GET /org/p/_apis/wit/workitems/{id} -> 404',
1155
+ ]);
1156
+ });
1157
+
1137
1158
  it('never leaks the PAT: credentials are applied to the axios config, not the description', async () => {
1138
1159
  mockAxiosInstance.request.mockRejectedValue(notFound());
1139
1160
 
@@ -6032,6 +6032,7 @@ describe('ResultDataProvider', () => {
6032
6032
  // record with the response summary as meta (docgen-log-redaction / Phase 2/4).
6033
6033
  expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary:', {
6034
6034
  message: 'boom',
6035
+ errMessage: 'boom',
6035
6036
  stack: expect.any(String),
6036
6037
  responseSummary: '{"detail":"bad"}',
6037
6038
  });
@@ -1,6 +1,6 @@
1
1
  import * as winston from 'winston';
2
2
  import Transport = require('winston-transport');
3
- import { redact, DiagnosticsTransport } from '../../utils/logger';
3
+ import { redact, DiagnosticsTransport, isSensitiveKey, skipUncaptured } from '../../utils/logger';
4
4
  import { installLogSink, LogSink, DiagnosticEvent } from '../../utils/logSink';
5
5
  import { runContextStore } from '../../utils/runContext';
6
6
 
@@ -379,3 +379,37 @@ describe('DiagnosticsTransport capture policy (Phase 6b)', () => {
379
379
  expect(events[0].message).toBe('verbose debug');
380
380
  });
381
381
  });
382
+
383
+ describe('isSensitiveKey', () => {
384
+ it.each(['token', 'accessToken', 'x-docgen-ingest-token', 'PAT', 'pat', 'password', 'DB_PASSWORD', 'Authorization', 'minioSecretKey', 'minioAccessKey', 'apiKey', 'clientSecret', 'cookie'])(
385
+ 'redacts %s',
386
+ (key) => expect(isSensitiveKey(key)).toBe(true)
387
+ );
388
+ it.each(['areaPath', 'IterationPath', 'path', 'filePath', 'patch', 'dispatch', 'compatibility', 'tokenCount', 'System.AreaPath', 'url'])(
389
+ 'keeps %s',
390
+ (key) => expect(isSensitiveKey(key)).toBe(false)
391
+ );
392
+ it('redact() leaves areaPath visible but scrubs a token', () => {
393
+ const out = applyRedact({ message: 'm', areaPath: 'P\\Area', accessToken: 'abc' });
394
+ expect(out.areaPath).toBe('P\\Area');
395
+ expect(out.accessToken).toBe('[REDACTED]');
396
+ });
397
+ });
398
+
399
+ describe('skipUncaptured', () => {
400
+ const run = (level: string) => (skipUncaptured() as any).transform({ level, message: 'm' });
401
+ it('keeps what stdout prints and always keeps warn/error', () => {
402
+ expect(run('error')).toBeTruthy();
403
+ expect(run('warn')).toBeTruthy();
404
+ expect(run('info')).toBeTruthy();
405
+ });
406
+ it('drops debug in normal mode', () => {
407
+ expect(run('debug')).toBe(false);
408
+ });
409
+ it('keeps debug inside a verbose run', () => {
410
+ const { runContextStore } = require('../../utils/runContext');
411
+ runContextStore.run({ runId: 'r1', captureMode: 'verbose' }, () => {
412
+ expect(run('debug')).toBeTruthy();
413
+ });
414
+ });
415
+ });
@@ -29,7 +29,24 @@ export const withRunContext = winston.format((info) => {
29
29
  // "accesskey" (not just "minioaccesskey") so this also catches an *AccessKeyId-style field —
30
30
  // a real gap found in api-gate's copy during the Phase 5 manifest work: only the *SecretKey
31
31
  // sibling was covered before, via "secret". Applied here too to keep the four copies in sync.
32
- const SENSITIVE_KEY = /token|pat|password|secret|authorization|accesskey|minioaccesskey|miniosecretkey/i;
32
+ // Word-based, not substring: the key is split into lower-case words (camelCase, kebab-case and
33
+ // snake_case alike) and judged by how it *ends*. A substring test matched "pat" inside
34
+ // "areaPath" / "path" / "patch" and blanked ordinary ADO fields; this keeps `accessToken`,
35
+ // `x-docgen-ingest-token`, `minioSecretKey`, `PAT`, `password` redacted while `areaPath` stays.
36
+ const SENSITIVE_TAILS = ['token', 'password', 'secret', 'authorization', 'accesskey', 'secretkey', 'apikey'];
37
+ const SENSITIVE_LAST_WORDS = new Set(['pat', 'pwd', 'cookie', 'tokens', 'passwords', 'secrets']);
38
+ export function isSensitiveKey(key: string): boolean {
39
+ const words = key
40
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
41
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
42
+ .toLowerCase()
43
+ .split(/[^a-z0-9]+/)
44
+ .filter(Boolean);
45
+ if (words.length === 0) return false;
46
+ if (SENSITIVE_LAST_WORDS.has(words[words.length - 1])) return true;
47
+ const tail = words.slice(-2).join('');
48
+ return SENSITIVE_TAILS.some((t) => tail.endsWith(t));
49
+ }
33
50
 
34
51
  // A per-key try/catch on the *read*, not just around the whole loop: a getter that throws
35
52
  // (e.g. `{ get boom() { throw ... } }`) would otherwise abort redaction for every remaining
@@ -56,7 +73,7 @@ export function redactValue(value: unknown, depth = 0, seen = new WeakSet<object
56
73
  out[key] = '[Unreadable property]';
57
74
  continue;
58
75
  }
59
- out[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : redactValue(read.value, depth + 1, seen);
76
+ out[key] = isSensitiveKey(key) ? '[REDACTED]' : redactValue(read.value, depth + 1, seen);
60
77
  }
61
78
  return out;
62
79
  }
@@ -75,7 +92,7 @@ export const redact = winston.format((info) => {
75
92
  ? '[Unreadable property]'
76
93
  : // A top-level primitive (e.g. token: "abc") has no children for redactValue to walk into —
77
94
  // the key itself has to be checked here too, not just inside the recursive object walk.
78
- SENSITIVE_KEY.test(key)
95
+ isSensitiveKey(key)
79
96
  ? '[REDACTED]'
80
97
  : redactValue(read.value);
81
98
  try {
@@ -216,7 +233,9 @@ export class DiagnosticsTransport extends Transport {
216
233
  message,
217
234
  err: hasErr
218
235
  ? {
219
- message,
236
+ // The error's own message when the call site supplied it (describeError); else
237
+ // the combined winston message, which is all a bare logger.error(err) leaves.
238
+ message: clamp(info.errMessage, MAX_MESSAGE_LEN) ?? message,
220
239
  code: typeof info.code === 'string' ? info.code : undefined,
221
240
  stack: clamp(info.stack, MAX_STACK_LEN),
222
241
  }
@@ -248,11 +267,29 @@ const useJson = (process.env.LOG_FORMAT || 'text').toLowerCase() === 'json';
248
267
  // for free; info/warn/error volume (Phase 4's focus) is unaffected either way.
249
268
  const CONSOLE_LOG_LEVEL = process.env.LOG_LEVEL || 'info';
250
269
 
270
+ // Drops a record before the (comparatively expensive) redact/splat/format work when nothing
271
+ // would consume it: stdout wouldn't print it, and DiagnosticsTransport wouldn't persist it
272
+ // (only debug/info under a verbose/retain-on-failure run are captured below warn). The logger's
273
+ // own level gate must stay 'debug' for that capture, so this is the cheap early exit instead.
274
+ const LEVEL_RANK: Record<string, number> = { error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 };
275
+ export const skipUncaptured = winston.format((info) => {
276
+ const printRank = LEVEL_RANK[CONSOLE_LOG_LEVEL];
277
+ const rank = LEVEL_RANK[String(info.level)];
278
+ if (printRank === undefined || rank === undefined || rank <= printRank) return info;
279
+ const mode = runContextStore.getStore()?.captureMode;
280
+ const captured =
281
+ DIAGNOSTICS_CAPTURE_ENABLED &&
282
+ (info.level === 'debug' || info.level === 'info') &&
283
+ (mode === 'verbose' || mode === 'retain-on-failure');
284
+ return captured ? info : false;
285
+ });
286
+
251
287
  logger = winston.createLogger({
252
288
  level: 'debug',
253
289
  defaultMeta: { service: '@elisra-devops/docgen-data-provider', version: readOwnVersion() },
254
290
  format: useJson
255
291
  ? winston.format.combine(
292
+ skipUncaptured(),
256
293
  winston.format.errors({ stack: true }),
257
294
  winston.format.timestamp(),
258
295
  withRunContext(),
@@ -261,6 +298,7 @@ logger = winston.createLogger({
261
298
  winston.format.json()
262
299
  )
263
300
  : winston.format.combine(
301
+ skipUncaptured(),
264
302
  winston.format.errors({ stack: true }),
265
303
  winston.format.timestamp(),
266
304
  withRunContext(),