@elisra-devops/docgen-data-provider 1.141.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.
- package/bin/helpers/requestContext.d.ts +66 -0
- package/bin/helpers/requestContext.js +255 -0
- package/bin/helpers/requestContext.js.map +1 -0
- package/bin/helpers/tfs.d.ts +5 -0
- package/bin/helpers/tfs.js +31 -31
- package/bin/helpers/tfs.js.map +1 -1
- package/bin/modules/GitDataProvider.js +3 -2
- package/bin/modules/GitDataProvider.js.map +1 -1
- package/bin/modules/JfrogDataProvider.js +2 -1
- package/bin/modules/JfrogDataProvider.js.map +1 -1
- package/bin/modules/PipelinesDataProvider.js +4 -3
- package/bin/modules/PipelinesDataProvider.js.map +1 -1
- package/bin/modules/ResultDataProvider.js +12 -11
- package/bin/modules/ResultDataProvider.js.map +1 -1
- package/bin/modules/TestDataProvider.js +4 -3
- package/bin/modules/TestDataProvider.js.map +1 -1
- package/bin/modules/TicketsDataProvider.js +13 -12
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/helpers/requestContext.test.d.ts +1 -0
- package/bin/tests/helpers/requestContext.test.js +204 -0
- package/bin/tests/helpers/requestContext.test.js.map +1 -0
- package/bin/tests/helpers/tfs.test.js +73 -0
- package/bin/tests/helpers/tfs.test.js.map +1 -1
- package/bin/tests/modules/JfrogDataProvider.test.js +1 -1
- package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -1
- package/bin/tests/modules/ResultDataProvider.test.js +5 -2
- package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
- package/bin/tests/modules/gitDataProvider.test.js +1 -1
- package/bin/tests/modules/gitDataProvider.test.js.map +1 -1
- package/bin/tests/modules/pipelineDataProvider.test.js +1 -1
- package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -1
- package/bin/tests/modules/testDataProvider.test.js +1 -1
- package/bin/tests/modules/testDataProvider.test.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.test.js +2 -2
- package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -1
- package/bin/tests/utils/logger.test.js +120 -0
- package/bin/tests/utils/logger.test.js.map +1 -1
- package/bin/utils/logSink.d.ts +14 -0
- package/bin/utils/logSink.js +12 -0
- package/bin/utils/logSink.js.map +1 -1
- package/bin/utils/logger.d.ts +3 -0
- package/bin/utils/logger.js +96 -10
- package/bin/utils/logger.js.map +1 -1
- package/package.json +1 -1
- package/src/helpers/requestContext.ts +258 -0
- package/src/helpers/tfs.ts +38 -28
- package/src/modules/GitDataProvider.ts +3 -2
- package/src/modules/JfrogDataProvider.ts +2 -1
- package/src/modules/PipelinesDataProvider.ts +4 -3
- package/src/modules/ResultDataProvider.ts +12 -11
- package/src/modules/TestDataProvider.ts +4 -3
- package/src/modules/TicketsDataProvider.ts +13 -12
- package/src/tests/helpers/requestContext.test.ts +252 -0
- package/src/tests/helpers/tfs.test.ts +107 -0
- package/src/tests/modules/JfrogDataProvider.test.ts +2 -1
- package/src/tests/modules/ResultDataProvider.test.ts +6 -2
- package/src/tests/modules/gitDataProvider.test.ts +2 -1
- package/src/tests/modules/pipelineDataProvider.test.ts +2 -1
- package/src/tests/modules/testDataProvider.test.ts +4 -1
- package/src/tests/modules/ticketsDataProvider.test.ts +2 -2
- package/src/tests/utils/logger.test.ts +146 -1
- package/src/utils/logSink.ts +18 -3
- package/src/utils/logger.ts +85 -7
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import logger from '../../utils/logger';
|
|
2
|
+
import {
|
|
3
|
+
sanitizeUrl,
|
|
4
|
+
summarizeBody,
|
|
5
|
+
summarizeResponse,
|
|
6
|
+
buildAdoRequestContext,
|
|
7
|
+
annotateAdoError,
|
|
8
|
+
maskWiqlLiterals,
|
|
9
|
+
templatePath,
|
|
10
|
+
describeError,
|
|
11
|
+
logCaughtError,
|
|
12
|
+
} from '../../helpers/requestContext';
|
|
13
|
+
|
|
14
|
+
describe('maskWiqlLiterals', () => {
|
|
15
|
+
it('replaces every quoted literal with its length', () => {
|
|
16
|
+
expect(maskWiqlLiterals("a = 'xy' and b = ''")).toBe("a = '[2 chars]' and b = '[0 chars]'");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('sanitizeUrl', () => {
|
|
21
|
+
it('returns undefined for a missing or empty url', () => {
|
|
22
|
+
expect(sanitizeUrl(undefined)).toBeUndefined();
|
|
23
|
+
expect(sanitizeUrl('')).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps a normal ADO url intact', () => {
|
|
27
|
+
const url = 'https://dev.azure.com/org/proj/_apis/wit/queries/abc?$depth=1&api-version=7.0';
|
|
28
|
+
expect(sanitizeUrl(url)).toBe(url);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('strips credentials embedded in the url', () => {
|
|
32
|
+
const out = sanitizeUrl('https://user:s3cret@dev.azure.com/org/_apis/projects');
|
|
33
|
+
expect(out).not.toContain('s3cret');
|
|
34
|
+
expect(out).not.toContain('user');
|
|
35
|
+
expect(out).toContain('dev.azure.com/org/_apis/projects');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('masks sensitive query parameter values but keeps ordinary ones', () => {
|
|
39
|
+
const out = sanitizeUrl('https://host/api?access_token=abc123&top=10&sig=zzz&keywords=release')!;
|
|
40
|
+
expect(out).not.toContain('abc123');
|
|
41
|
+
expect(out).not.toContain('zzz');
|
|
42
|
+
expect(out).toContain('top=10');
|
|
43
|
+
expect(out).toContain('keywords=release');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('leaves non-sensitive parameters byte-identical when masking another', () => {
|
|
47
|
+
expect(sanitizeUrl('https://h/api?$filter=a%20b&token=x&q=a+b')).toBe('https://h/api?$filter=a%20b&token=[REDACTED]&q=a+b');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('passes a non-absolute url through, bounded', () => {
|
|
51
|
+
expect(sanitizeUrl('/relative/path')).toBe('/relative/path');
|
|
52
|
+
expect(sanitizeUrl('x'.repeat(5000))!.length).toBeLessThanOrEqual(1001);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('summarizeBody', () => {
|
|
57
|
+
it('never describes a GET body', () => {
|
|
58
|
+
expect(summarizeBody({ query: 'SELECT 1' }, 'get')).toBeUndefined();
|
|
59
|
+
expect(summarizeBody({ query: 'SELECT 1' }, 'HEAD')).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns undefined for an empty body — the default `{}` callers pass', () => {
|
|
63
|
+
expect(summarizeBody({}, 'post')).toBeUndefined();
|
|
64
|
+
expect(summarizeBody([], 'post')).toBeUndefined();
|
|
65
|
+
expect(summarizeBody(null, 'post')).toBeUndefined();
|
|
66
|
+
expect(summarizeBody('', 'post')).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('keeps a WIQL-style body so the failing query is visible', () => {
|
|
70
|
+
const out = summarizeBody({ query: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = 'P'" }, 'post')!;
|
|
71
|
+
expect(out).toContain('SELECT [System.Id] FROM WorkItems');
|
|
72
|
+
expect(out).toContain("[System.TeamProject] = '[1 chars]'");
|
|
73
|
+
expect(out).not.toContain("= 'P'");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('masks WIQL literals (incl. escaped quotes) but keeps fields, numbers and macros', () => {
|
|
77
|
+
const out = summarizeBody({ query: "SELECT [Id] FROM WorkItems WHERE [Title] = 'it''s secret' AND [Assigned] = @Me AND [Id] > 42" }, 'post')!;
|
|
78
|
+
expect(out).not.toContain('secret');
|
|
79
|
+
expect(out).toContain("[Title] = '[12 chars]'");
|
|
80
|
+
expect(out).toContain('@Me');
|
|
81
|
+
expect(out).toContain('> 42');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps an id-list body', () => {
|
|
85
|
+
expect(summarizeBody({ ids: [1, 2, 3], fields: ['System.Title'] }, 'post')).toBe(
|
|
86
|
+
'{"ids":[1,2,3],"fields":["System.Title"]}'
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('redacts credential-looking keys at any depth', () => {
|
|
91
|
+
const out = summarizeBody({ query: 'q', auth: { token: 'tok-123', password: 'pw-456' }, pat: 'pat-789' }, 'post')!;
|
|
92
|
+
expect(out).not.toContain('tok-123');
|
|
93
|
+
expect(out).not.toContain('pw-456');
|
|
94
|
+
expect(out).not.toContain('pat-789');
|
|
95
|
+
expect(out).toContain('[REDACTED]');
|
|
96
|
+
expect(out).toContain('"query":"q"');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('JSON-Patch bodies keep op + path only — field values are dropped', () => {
|
|
100
|
+
const patch = [
|
|
101
|
+
{ op: 'add', path: '/fields/System.Title', value: 'Highly classified title' },
|
|
102
|
+
{ op: 'replace', path: '/fields/System.Description', value: { html: '<p>secret</p>' } },
|
|
103
|
+
];
|
|
104
|
+
const out = summarizeBody(patch, 'patch')!;
|
|
105
|
+
expect(out).toContain('json-patch (2 ops, values omitted)');
|
|
106
|
+
expect(out).toContain('add /fields/System.Title');
|
|
107
|
+
expect(out).toContain('replace /fields/System.Description');
|
|
108
|
+
expect(out).not.toContain('classified');
|
|
109
|
+
expect(out).not.toContain('secret');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('bounds a long string value and the whole body', () => {
|
|
113
|
+
const one = summarizeBody({ query: 'x'.repeat(5000) }, 'post')!;
|
|
114
|
+
expect(one.length).toBeLessThan(700);
|
|
115
|
+
const many = summarizeBody(Object.fromEntries(Array.from({ length: 300 }, (_, i) => [`k${i}`, 'v'.repeat(40)])), 'post')!;
|
|
116
|
+
expect(many.length).toBeLessThanOrEqual(2001);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('caps a very long array', () => {
|
|
120
|
+
const out = summarizeBody({ ids: Array.from({ length: 500 }, (_, i) => i) }, 'post')!;
|
|
121
|
+
expect(out).toContain('more');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('describes non-JSON bodies by type and size only', () => {
|
|
125
|
+
expect(summarizeBody(Buffer.from('abcdef'), 'post')).toBe('[binary, 6 bytes]');
|
|
126
|
+
expect(summarizeBody('not json at all', 'post')).toBe('[text, 15 chars]');
|
|
127
|
+
expect(summarizeBody(42, 'post')).toBe('[number]');
|
|
128
|
+
expect(summarizeBody({ pipe: () => undefined }, 'post')).toBe('[stream]');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('parses a JSON string body and summarizes it like an object', () => {
|
|
132
|
+
expect(summarizeBody('{"query":"q"}', 'post')).toBe('{"query":"q"}');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('never throws on a body it cannot serialize', () => {
|
|
136
|
+
expect(summarizeBody({ n: BigInt(1) }, 'post')).toBe('[unserializable body]');
|
|
137
|
+
const circular: Record<string, unknown> = { a: 1 };
|
|
138
|
+
circular.self = circular;
|
|
139
|
+
expect(() => summarizeBody(circular, 'post')).not.toThrow();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('summarizeResponse', () => {
|
|
144
|
+
it('prefers the server message (ADO puts its explanation there)', () => {
|
|
145
|
+
expect(summarizeResponse({ message: 'TF401232: Work item 5 does not exist', typeName: 'x' })).toBe(
|
|
146
|
+
'TF401232: Work item 5 does not exist'
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('truncates a text response (e.g. an HTML error page) to 200 chars', () => {
|
|
151
|
+
expect(summarizeResponse('<html>'.repeat(200))!.length).toBeLessThanOrEqual(201);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('falls back to a redacted, bounded JSON dump when there is no message', () => {
|
|
155
|
+
const out = summarizeResponse({ code: 1, token: 'leak-me' })!;
|
|
156
|
+
expect(out).not.toContain('leak-me');
|
|
157
|
+
expect(out).toContain('"code":1');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does not walk a stream response', () => {
|
|
161
|
+
expect(summarizeResponse({ pipe: () => undefined, socket: { a: 1 } })).toBe('[stream]');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('ignores a missing or binary response', () => {
|
|
165
|
+
expect(summarizeResponse(undefined)).toBeUndefined();
|
|
166
|
+
expect(summarizeResponse(Buffer.from('x'))).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('buildAdoRequestContext', () => {
|
|
171
|
+
it('describes a failed GET with method, status, attempt and the server message', () => {
|
|
172
|
+
const err = { response: { status: 404, data: { message: 'not found' } } };
|
|
173
|
+
expect(buildAdoRequestContext(err, { method: 'get', url: 'https://h/_apis/x' }, 2)).toEqual({
|
|
174
|
+
method: 'GET',
|
|
175
|
+
url: 'https://h/_apis/x',
|
|
176
|
+
status: 404,
|
|
177
|
+
attempt: 2,
|
|
178
|
+
responseExcerpt: 'not found',
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('includes a summarized body for a POST and omits fields it does not have', () => {
|
|
183
|
+
const ctx = buildAdoRequestContext(new Error('boom'), { method: 'post', url: 'https://h/wiql', data: { query: 'q' } });
|
|
184
|
+
expect(ctx).toEqual({ method: 'POST', url: 'https://h/wiql', requestBody: '{"query":"q"}' });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('falls back to the axios error config when the request is not described explicitly', () => {
|
|
188
|
+
const ctx = buildAdoRequestContext({ config: { method: 'put', url: 'https://h/y' } }, {});
|
|
189
|
+
expect(ctx.method).toBe('PUT');
|
|
190
|
+
expect(ctx.url).toBe('https://h/y');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('annotateAdoError', () => {
|
|
195
|
+
it('attaches the context as an enumerable property winston will merge onto the record', () => {
|
|
196
|
+
const err = annotateAdoError(new Error('x'), { method: 'GET', url: 'https://h/z' });
|
|
197
|
+
expect(Object.keys(err)).toContain('adoRequest');
|
|
198
|
+
expect((err as any).adoRequest.url).toBe('https://h/z');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('never throws on a frozen or non-object error', () => {
|
|
202
|
+
expect(() => annotateAdoError(Object.freeze(new Error('x')), { url: 'u' })).not.toThrow();
|
|
203
|
+
expect(() => annotateAdoError('a string' as any, { url: 'u' })).not.toThrow();
|
|
204
|
+
expect(() => annotateAdoError(undefined as any, { url: 'u' })).not.toThrow();
|
|
205
|
+
});
|
|
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
|
+
});
|
|
@@ -1089,3 +1089,110 @@ describe('TFSServices', () => {
|
|
|
1089
1089
|
});
|
|
1090
1090
|
});
|
|
1091
1091
|
});
|
|
1092
|
+
|
|
1093
|
+
describe('TFSServices request-context annotation', () => {
|
|
1094
|
+
// Math.random is pinned and setTimeout collapsed so a retried failure doesn't slow the suite.
|
|
1095
|
+
beforeEach(() => {
|
|
1096
|
+
jest.clearAllMocks();
|
|
1097
|
+
mockAxiosInstance.request.mockReset();
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
const notFound = () => {
|
|
1101
|
+
const e: any = new Error('Request failed with status code 404');
|
|
1102
|
+
e.response = { status: 404, data: { message: 'TF401232: Work item 5 does not exist' } };
|
|
1103
|
+
return e;
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
it('annotates the thrown error with the request even when printError is false, without logging', async () => {
|
|
1107
|
+
mockAxiosInstance.request.mockRejectedValue(notFound());
|
|
1108
|
+
|
|
1109
|
+
const caught = await TFSServices.getItemContent('https://dev.azure.com/org/_apis/wit/queries/q1', 'pat', 'get', {}, {}, false).catch(
|
|
1110
|
+
(e) => e
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
expect(caught.adoRequest).toEqual({
|
|
1114
|
+
method: 'GET',
|
|
1115
|
+
url: 'https://dev.azure.com/org/_apis/wit/queries/q1',
|
|
1116
|
+
status: 404,
|
|
1117
|
+
attempt: 1,
|
|
1118
|
+
reported: false,
|
|
1119
|
+
responseExcerpt: 'TF401232: Work item 5 does not exist',
|
|
1120
|
+
});
|
|
1121
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it('with printError, also emits the one "ADO request failed" record carrying the same context', async () => {
|
|
1125
|
+
mockAxiosInstance.request.mockRejectedValue(notFound());
|
|
1126
|
+
|
|
1127
|
+
await TFSServices.getItemContent('https://dev.azure.com/org/_apis/x', 'pat').catch(() => undefined);
|
|
1128
|
+
|
|
1129
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
1130
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
1131
|
+
'ADO request failed: GET /org/_apis/x -> 404',
|
|
1132
|
+
expect.objectContaining({
|
|
1133
|
+
adoRequest: expect.objectContaining({
|
|
1134
|
+
method: 'GET',
|
|
1135
|
+
url: 'https://dev.azure.com/org/_apis/x',
|
|
1136
|
+
status: 404,
|
|
1137
|
+
reported: true,
|
|
1138
|
+
}),
|
|
1139
|
+
})
|
|
1140
|
+
);
|
|
1141
|
+
});
|
|
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
|
+
|
|
1158
|
+
it('never leaks the PAT: credentials are applied to the axios config, not the description', async () => {
|
|
1159
|
+
mockAxiosInstance.request.mockRejectedValue(notFound());
|
|
1160
|
+
|
|
1161
|
+
const caught = await TFSServices.getItemContent('https://dev.azure.com/org/_apis/x', 'super-secret-pat', 'get', {}, {}, false).catch(
|
|
1162
|
+
(e) => e
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
expect(JSON.stringify(caught.adoRequest)).not.toContain('super-secret-pat');
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it('postRequest describes a JSON-Patch body by op + path only', async () => {
|
|
1169
|
+
mockAxiosInstance.request.mockRejectedValue(notFound());
|
|
1170
|
+
|
|
1171
|
+
const caught = await TFSServices.postRequest(
|
|
1172
|
+
'https://dev.azure.com/org/_apis/wit/workitems/9',
|
|
1173
|
+
'pat',
|
|
1174
|
+
'PATCH',
|
|
1175
|
+
[{ op: 'add', path: '/fields/System.Title', value: 'confidential title' }]
|
|
1176
|
+
).catch((e) => e);
|
|
1177
|
+
|
|
1178
|
+
expect(caught.adoRequest.method).toBe('PATCH');
|
|
1179
|
+
expect(caught.adoRequest.requestBody).toContain('add /fields/System.Title');
|
|
1180
|
+
expect(caught.adoRequest.requestBody).not.toContain('confidential');
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it('getJfrogRequest annotates too', async () => {
|
|
1184
|
+
mockAxiosInstance.request.mockRejectedValue(notFound());
|
|
1185
|
+
|
|
1186
|
+
const caught = await TFSServices.getJfrogRequest('https://jfrog.example/api/x').catch((e) => e);
|
|
1187
|
+
|
|
1188
|
+
expect(caught.adoRequest).toEqual(expect.objectContaining({ method: 'GET', url: 'https://jfrog.example/api/x', status: 404 }));
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it('downloadZipFile annotates the error it rethrows', async () => {
|
|
1192
|
+
mockAxiosInstance.request.mockRejectedValue(notFound());
|
|
1193
|
+
|
|
1194
|
+
const caught = await TFSServices.downloadZipFile('https://dev.azure.com/org/file.zip', 'pat').catch((e) => e);
|
|
1195
|
+
|
|
1196
|
+
expect(caught.adoRequest).toEqual(expect.objectContaining({ method: 'GET', url: 'https://dev.azure.com/org/file.zip', status: 404 }));
|
|
1197
|
+
});
|
|
1198
|
+
});
|
|
@@ -162,7 +162,8 @@ describe('JfrogDataProvider', () => {
|
|
|
162
162
|
).rejects.toThrow('JFrog API error');
|
|
163
163
|
|
|
164
164
|
expect(logger.error).toHaveBeenCalledWith(
|
|
165
|
-
|
|
165
|
+
'Error occurred during querying JFrog using:',
|
|
166
|
+
expect.objectContaining({ message: 'JFrog API error' })
|
|
166
167
|
);
|
|
167
168
|
});
|
|
168
169
|
});
|
|
@@ -5324,7 +5324,8 @@ describe('ResultDataProvider', () => {
|
|
|
5324
5324
|
);
|
|
5325
5325
|
|
|
5326
5326
|
expect(logger.error).toHaveBeenCalledWith(
|
|
5327
|
-
|
|
5327
|
+
'Could not append related work item to test case 999:',
|
|
5328
|
+
expect.objectContaining({ message: 'network' })
|
|
5328
5329
|
);
|
|
5329
5330
|
});
|
|
5330
5331
|
});
|
|
@@ -6029,7 +6030,10 @@ describe('ResultDataProvider', () => {
|
|
|
6029
6030
|
|
|
6030
6031
|
// Was two separate calls (message, then the response body) — consolidated into one
|
|
6031
6032
|
// record with the response summary as meta (docgen-log-redaction / Phase 2/4).
|
|
6032
|
-
expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary:
|
|
6033
|
+
expect(logger.error).toHaveBeenCalledWith('Error during getCombinedResultsSummary:', {
|
|
6034
|
+
message: 'boom',
|
|
6035
|
+
errMessage: 'boom',
|
|
6036
|
+
stack: expect.any(String),
|
|
6033
6037
|
responseSummary: '{"detail":"bad"}',
|
|
6034
6038
|
});
|
|
6035
6039
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
@@ -367,7 +367,8 @@ describe('GitDataProvider - GetFileFromGitRepo', () => {
|
|
|
367
367
|
|
|
368
368
|
// Assert
|
|
369
369
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
370
|
-
|
|
370
|
+
`File ${mockFileName} could not be read:`,
|
|
371
|
+
expect.objectContaining({ message: mockError.message })
|
|
371
372
|
);
|
|
372
373
|
expect(result).toBeUndefined();
|
|
373
374
|
});
|
|
@@ -305,7 +305,8 @@ describe('PipelinesDataProvider', () => {
|
|
|
305
305
|
null
|
|
306
306
|
);
|
|
307
307
|
expect(logger.error).toHaveBeenCalledWith(
|
|
308
|
-
|
|
308
|
+
'Could not fetch Pipeline Run History:',
|
|
309
|
+
expect.objectContaining({ message: expectedError.message })
|
|
309
310
|
);
|
|
310
311
|
expect(result).toBeUndefined();
|
|
311
312
|
});
|
|
@@ -105,7 +105,10 @@ describe('TestDataProvider', () => {
|
|
|
105
105
|
|
|
106
106
|
// Act & Assert
|
|
107
107
|
await expect(invokeFetchWithCache(testDataProvider, mockUrl)).rejects.toThrow('API call failed');
|
|
108
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
108
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
109
|
+
`Error fetching ${mockUrl}:`,
|
|
110
|
+
expect.objectContaining({ message: 'API call failed' })
|
|
111
|
+
);
|
|
109
112
|
});
|
|
110
113
|
});
|
|
111
114
|
|
|
@@ -1108,7 +1108,7 @@ describe('TicketsDataProvider', () => {
|
|
|
1108
1108
|
expect(() =>
|
|
1109
1109
|
(ticketsDataProvider as any).filterFieldsByColumns(item, columnsToFilterMap, resultedRefNameMap),
|
|
1110
1110
|
).toThrow();
|
|
1111
|
-
expect(logger.error).toHaveBeenCalledWith(
|
|
1111
|
+
expect(logger.error).toHaveBeenCalledWith('Cannot filter columns:', expect.objectContaining({ message: expect.any(String) }));
|
|
1112
1112
|
});
|
|
1113
1113
|
});
|
|
1114
1114
|
|
|
@@ -2779,7 +2779,7 @@ describe('TicketsDataProvider', () => {
|
|
|
2779
2779
|
expect(res.categories['Security and Privacy Requirements']).toHaveLength(1);
|
|
2780
2780
|
expect(res.categories['Precedence and Criticality of Requirements']).toHaveLength(1);
|
|
2781
2781
|
expect(res.categories['Other Requirements']).toHaveLength(1);
|
|
2782
|
-
expect(logger.warn).toHaveBeenCalledWith(
|
|
2782
|
+
expect(logger.warn).toHaveBeenCalledWith('Could not fetch work item 13:', expect.objectContaining({ message: 'boom' }));
|
|
2783
2783
|
});
|
|
2784
2784
|
|
|
2785
2785
|
it('should extract IDs from workItemRelations for OneHop queries', async () => {
|
|
@@ -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
|
|
|
@@ -169,6 +169,117 @@ describe('DiagnosticsTransport (Phase 6a)', () => {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
describe('DiagnosticsTransport request context', () => {
|
|
173
|
+
function makeLogger() {
|
|
174
|
+
return winston.createLogger({
|
|
175
|
+
level: 'silly',
|
|
176
|
+
format: winston.format.combine(
|
|
177
|
+
winston.format.errors({ stack: true }),
|
|
178
|
+
winston.format.timestamp(),
|
|
179
|
+
redact(),
|
|
180
|
+
winston.format.splat(),
|
|
181
|
+
winston.format.json()
|
|
182
|
+
),
|
|
183
|
+
transports: [new DiagnosticsTransport()],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
test('persists an annotated error\'s request as event.context, however the call site words its message', () => {
|
|
188
|
+
const events: DiagnosticEvent[] = [];
|
|
189
|
+
installLogSink({ push: (e) => events.push(e) });
|
|
190
|
+
const err = Object.assign(new Error('Request failed with status code 404'), {
|
|
191
|
+
adoRequest: { method: 'GET', url: 'https://dev.azure.com/org/_apis/x', status: 404, attempt: 1, responseExcerpt: 'nope' },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
makeLogger().error(err); // the bare-error shape (3 call sites) — no stable message at all
|
|
195
|
+
|
|
196
|
+
expect(events[0].context).toEqual({
|
|
197
|
+
method: 'GET',
|
|
198
|
+
url: 'https://dev.azure.com/org/_apis/x',
|
|
199
|
+
status: 404,
|
|
200
|
+
attempt: 1,
|
|
201
|
+
responseExcerpt: 'nope',
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('also works for the stable-message + error shape and for the explicit meta shape', () => {
|
|
206
|
+
const events: DiagnosticEvent[] = [];
|
|
207
|
+
installLogSink({ push: (e) => events.push(e) });
|
|
208
|
+
const logger = makeLogger();
|
|
209
|
+
const ctx = { method: 'POST', url: 'https://h/wiql', requestBody: '{"query":"q"}' };
|
|
210
|
+
|
|
211
|
+
logger.error('Failed querying work items', Object.assign(new Error('boom'), { adoRequest: ctx }));
|
|
212
|
+
logger.error('ADO request failed', { message: 'boom', stack: 'at x', adoRequest: ctx });
|
|
213
|
+
|
|
214
|
+
expect(events[0].context).toEqual(ctx);
|
|
215
|
+
expect(events[1].context).toEqual(ctx);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('an event with no request context has no context field', () => {
|
|
219
|
+
const events: DiagnosticEvent[] = [];
|
|
220
|
+
installLogSink({ push: (e) => events.push(e) });
|
|
221
|
+
makeLogger().error('plain failure', new Error('boom'));
|
|
222
|
+
expect(events[0].context).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('re-validates the shape at the transport: unknown keys, wrong types and oversize values are dropped or bounded', () => {
|
|
226
|
+
const events: DiagnosticEvent[] = [];
|
|
227
|
+
installLogSink({ push: (e) => events.push(e) });
|
|
228
|
+
const err = Object.assign(new Error('x'), {
|
|
229
|
+
adoRequest: {
|
|
230
|
+
method: 'GET',
|
|
231
|
+
url: 'u'.repeat(5000),
|
|
232
|
+
status: 'not-a-number',
|
|
233
|
+
attempt: 2,
|
|
234
|
+
requestBody: 'b'.repeat(5000),
|
|
235
|
+
secretHeader: 'Bearer abc',
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
makeLogger().error('m', err);
|
|
240
|
+
|
|
241
|
+
const ctx = events[0].context!;
|
|
242
|
+
expect(ctx.url!.length).toBe(1000);
|
|
243
|
+
expect(ctx.requestBody!.length).toBe(2000);
|
|
244
|
+
expect(ctx.status).toBeUndefined();
|
|
245
|
+
expect(ctx.attempt).toBe(2);
|
|
246
|
+
expect(ctx).not.toHaveProperty('secretHeader');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('text format request suffix', () => {
|
|
251
|
+
// The default (LOG_FORMAT unset) prints only the message — without this the URL would never
|
|
252
|
+
// reach stdout for a record whose own message doesn't carry it. A stream transport on the
|
|
253
|
+
// real logger receives the already-formatted line, independent of how jest wires console.
|
|
254
|
+
test('appends the request to the printed line when the error carries one, and never throws', () => {
|
|
255
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
256
|
+
const logger = require('../../utils/logger').default;
|
|
257
|
+
const { Writable } = require('stream');
|
|
258
|
+
const lines: string[] = [];
|
|
259
|
+
const sink = new winston.transports.Stream({
|
|
260
|
+
stream: new Writable({
|
|
261
|
+
write(chunk: Buffer, _enc: string, cb: () => void) {
|
|
262
|
+
lines.push(chunk.toString());
|
|
263
|
+
cb();
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
});
|
|
267
|
+
logger.add(sink);
|
|
268
|
+
try {
|
|
269
|
+
const err = Object.assign(new Error('Request failed with status code 404'), {
|
|
270
|
+
adoRequest: { method: 'GET', url: 'https://dev.azure.com/org/_apis/x', status: 404 },
|
|
271
|
+
});
|
|
272
|
+
expect(() => logger.error('Failed fetching', err)).not.toThrow();
|
|
273
|
+
expect(() => logger.error('odd', Object.assign(new Error('e'), { adoRequest: { url: 123 } }))).not.toThrow();
|
|
274
|
+
} finally {
|
|
275
|
+
logger.remove(sink);
|
|
276
|
+
}
|
|
277
|
+
const joined = lines.join('');
|
|
278
|
+
expect(joined).toContain('[GET https://dev.azure.com/org/_apis/x -> 404]');
|
|
279
|
+
expect(joined).toContain('Failed fetching');
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
172
283
|
describe('textFormat safety (regression)', () => {
|
|
173
284
|
// The hostile-input matrix above exercises makeTestLogger()'s json() pipeline — it never
|
|
174
285
|
// touched the real singleton logger's text-format branch (LOG_FORMAT's default), which is
|
|
@@ -268,3 +379,37 @@ describe('DiagnosticsTransport capture policy (Phase 6b)', () => {
|
|
|
268
379
|
expect(events[0].message).toBe('verbose debug');
|
|
269
380
|
});
|
|
270
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
|
+
});
|
package/src/utils/logSink.ts
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
// The one queryable-error-store event shape, emitted by DiagnosticsTransport (logger.ts).
|
|
4
4
|
// This package never installs a sink itself — it only reads whatever the host process
|
|
5
|
-
// (docgen-content-control's index.ts) installed. err
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// (docgen-content-control's index.ts) installed. err stays narrow — {message, code, stack}.
|
|
6
|
+
// `context` is the one deliberate exception to "no extra-fields bucket": a sanitized
|
|
7
|
+
// description of the failed outbound request (method, url, status, attempt, request-body
|
|
8
|
+
// summary, response excerpt) — without it a 404 in the dashboard can't be traced to the call
|
|
9
|
+
// that produced it. A fixed, allowlisted shape, not a free-form bag.
|
|
10
|
+
// Bounds for DiagnosticEvent.context. Single source for this package (requestContext.ts and the
|
|
11
|
+
// logger transport); docgen-api-gate's DiagnosticsController.sanitizeContext re-validates at
|
|
12
|
+
// ingest with its own copy — keep the two in step.
|
|
13
|
+
export const CONTEXT_LIMITS = { method: 10, url: 1000, requestBody: 2000, responseExcerpt: 300 } as const;
|
|
14
|
+
|
|
8
15
|
export interface DiagnosticEvent {
|
|
9
16
|
ts: string;
|
|
10
17
|
level: string;
|
|
@@ -19,6 +26,14 @@ export interface DiagnosticEvent {
|
|
|
19
26
|
userId?: string;
|
|
20
27
|
message: string;
|
|
21
28
|
err?: { message: string; code?: string; stack?: string };
|
|
29
|
+
context?: {
|
|
30
|
+
method?: string;
|
|
31
|
+
url?: string;
|
|
32
|
+
status?: number;
|
|
33
|
+
attempt?: number;
|
|
34
|
+
requestBody?: string;
|
|
35
|
+
responseExcerpt?: string;
|
|
36
|
+
};
|
|
22
37
|
// Phase 6b — set on a debug/info event captured under retain-on-failure. Deleted by
|
|
23
38
|
// api-gate at the run's one success point; left alone (and thus permanent, subject to the
|
|
24
39
|
// normal TTL) if the run fails.
|