graphql-http 1.14.0 → 1.15.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/README.md +3 -1
- package/lib/audits/index.d.mts +1 -0
- package/lib/audits/index.d.ts +1 -0
- package/lib/audits/index.js +1 -0
- package/lib/audits/index.mjs +1 -0
- package/lib/audits/render.d.mts +8 -0
- package/lib/audits/render.d.ts +8 -0
- package/lib/audits/render.js +134 -0
- package/lib/audits/render.mjs +130 -0
- package/package.json +11 -8
- package/umd/graphql-http-audits.js +1003 -0
- package/umd/graphql-http-audits.min.js +1 -0
- package/umd/graphql-http-audits.min.js.gz +0 -0
- package/umd/graphql-http.min.js.gz +0 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
(function (global, factory) {
|
|
2
|
+
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('graphql')) :
|
|
3
|
+
typeof define === 'function' && define.amd ? define(['exports', 'graphql'], factory) :
|
|
4
|
+
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.graphqlHttpAudits = {}));
|
|
5
|
+
})(this, (function (exports) { 'use strict';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
*
|
|
9
|
+
* utils
|
|
10
|
+
*
|
|
11
|
+
*/
|
|
12
|
+
/** @private */
|
|
13
|
+
function extendedTypeof(val) {
|
|
14
|
+
if (val === null) {
|
|
15
|
+
return 'null';
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(val)) {
|
|
18
|
+
return 'array';
|
|
19
|
+
}
|
|
20
|
+
return typeof val;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
*
|
|
25
|
+
* audit/utils
|
|
26
|
+
*
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Wrap and prepare an audit for testing.
|
|
30
|
+
*
|
|
31
|
+
* @private
|
|
32
|
+
*/
|
|
33
|
+
function audit(id, name, fn) {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
name,
|
|
37
|
+
fn: async () => {
|
|
38
|
+
try {
|
|
39
|
+
await fn();
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
name,
|
|
43
|
+
status: 'ok',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (!(err instanceof AuditError)) {
|
|
48
|
+
// anything thrown that is not an assertion error is considered fatal
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
name,
|
|
54
|
+
status: name.startsWith('MUST')
|
|
55
|
+
? // only failing MUSTs are considered errors
|
|
56
|
+
'error'
|
|
57
|
+
: // everything else is optional and considered a warning
|
|
58
|
+
'warn',
|
|
59
|
+
reason: err.reason,
|
|
60
|
+
response: err.response,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Error thrown when an assertion test fails.
|
|
68
|
+
*
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
class AuditError {
|
|
72
|
+
constructor(response, reason) {
|
|
73
|
+
this.response = response;
|
|
74
|
+
this.reason = reason;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Will throw an AuditError if the assertion on Response fails.
|
|
79
|
+
*
|
|
80
|
+
* All fatal problems will throw an instance of an Error.
|
|
81
|
+
*
|
|
82
|
+
* The name "ressert" is a wordplay combining "response" and "assert".
|
|
83
|
+
*
|
|
84
|
+
* @private
|
|
85
|
+
*/
|
|
86
|
+
function ressert(res) {
|
|
87
|
+
return {
|
|
88
|
+
status: {
|
|
89
|
+
toBe(code) {
|
|
90
|
+
if (res.status !== code) {
|
|
91
|
+
throw new AuditError(res, `Response status code is not ${code}`);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
toBeBetween: (min, max) => {
|
|
95
|
+
if (!(min <= res.status && res.status <= max)) {
|
|
96
|
+
throw new AuditError(res, `Response status is not between ${min} and ${max}`);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
header(key) {
|
|
101
|
+
return {
|
|
102
|
+
toContain(part) {
|
|
103
|
+
var _a;
|
|
104
|
+
if (!((_a = res.headers.get(key)) === null || _a === void 0 ? void 0 : _a.includes(part))) {
|
|
105
|
+
throw new AuditError(res, `Response header ${key} does not contain ${part}`);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
notToContain(part) {
|
|
109
|
+
var _a;
|
|
110
|
+
if ((_a = res.headers.get(key)) === null || _a === void 0 ? void 0 : _a.includes(part)) {
|
|
111
|
+
throw new AuditError(res, `Response header ${key} contains ${part}`);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
bodyAsExecutionResult: {
|
|
117
|
+
data: {
|
|
118
|
+
async toBe(val) {
|
|
119
|
+
const body = await assertBodyAsExecutionResult(res);
|
|
120
|
+
if (body.data !== val) {
|
|
121
|
+
throw new AuditError(res, `Response body execution result data is not "${val}"`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
async toHaveProperty(key) {
|
|
126
|
+
const body = await assertBodyAsExecutionResult(res);
|
|
127
|
+
if (!(key in body)) {
|
|
128
|
+
throw new AuditError(res, `Response body execution result does not have a property "${key}"`);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
async notToHaveProperty(key) {
|
|
132
|
+
const body = await assertBodyAsExecutionResult(res);
|
|
133
|
+
if (key in body) {
|
|
134
|
+
throw new AuditError(res, `Response body execution result has a property "${key}"`);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** @private */
|
|
141
|
+
async function assertBodyAsExecutionResult(res) {
|
|
142
|
+
let decoded;
|
|
143
|
+
try {
|
|
144
|
+
const decoder = new TextDecoder('utf-8');
|
|
145
|
+
const buff = await res.arrayBuffer();
|
|
146
|
+
decoded = decoder.decode(buff);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
throw new AuditError(res, 'Response body is not UTF-8 encoded');
|
|
150
|
+
}
|
|
151
|
+
let body;
|
|
152
|
+
try {
|
|
153
|
+
body = JSON.parse(decoded);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
throw new AuditError(res, 'Response body is not valid JSON');
|
|
157
|
+
}
|
|
158
|
+
return body;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
*
|
|
163
|
+
* audit/server
|
|
164
|
+
*
|
|
165
|
+
*/
|
|
166
|
+
/**
|
|
167
|
+
* List of server audits required to check GraphQL over HTTP spec conformance.
|
|
168
|
+
*
|
|
169
|
+
* @category Audits
|
|
170
|
+
*/
|
|
171
|
+
function serverAudits(opts) {
|
|
172
|
+
const fetchFn = (opts.fetchFn || fetch);
|
|
173
|
+
return [
|
|
174
|
+
// Media Types
|
|
175
|
+
audit(
|
|
176
|
+
// TODO: convert to MUST after watershed
|
|
177
|
+
'22EB', 'SHOULD accept application/graphql-response+json and match the content-type', async () => {
|
|
178
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: {
|
|
181
|
+
'content-type': 'application/json',
|
|
182
|
+
accept: 'application/graphql-response+json',
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
185
|
+
});
|
|
186
|
+
ressert(res).status.toBe(200);
|
|
187
|
+
ressert(res)
|
|
188
|
+
.header('content-type')
|
|
189
|
+
.toContain('application/graphql-response+json');
|
|
190
|
+
}),
|
|
191
|
+
audit('4655', 'MUST accept application/json and match the content-type', async () => {
|
|
192
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: {
|
|
195
|
+
'content-type': 'application/json',
|
|
196
|
+
accept: 'application/json',
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
199
|
+
});
|
|
200
|
+
ressert(res).status.toBe(200);
|
|
201
|
+
ressert(res).header('content-type').toContain('application/json');
|
|
202
|
+
}),
|
|
203
|
+
audit('47DE', 'SHOULD accept */* and use application/json for the content-type', async () => {
|
|
204
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: {
|
|
207
|
+
'content-type': 'application/json',
|
|
208
|
+
accept: '*/*',
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
211
|
+
});
|
|
212
|
+
ressert(res).status.toBe(200);
|
|
213
|
+
ressert(res).header('content-type').toContain('application/json');
|
|
214
|
+
}),
|
|
215
|
+
audit('80D8', 'SHOULD assume application/json content-type when accept is missing', async () => {
|
|
216
|
+
const url = new URL(await getUrl(opts.url));
|
|
217
|
+
url.searchParams.set('query', '{ __typename }');
|
|
218
|
+
const res = await fetchFn(url.toString());
|
|
219
|
+
ressert(res).status.toBe(200);
|
|
220
|
+
ressert(res).header('content-type').toContain('application/json');
|
|
221
|
+
}),
|
|
222
|
+
audit('82A3', 'MUST use utf-8 encoding when responding', async () => {
|
|
223
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: {
|
|
226
|
+
'content-type': 'application/json',
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
229
|
+
});
|
|
230
|
+
ressert(res).status.toBe(200);
|
|
231
|
+
try {
|
|
232
|
+
const decoder = new TextDecoder('utf-8');
|
|
233
|
+
decoder.decode(await res.arrayBuffer());
|
|
234
|
+
}
|
|
235
|
+
catch (_a) {
|
|
236
|
+
throw new AuditError(res, 'Response body is not UTF-8 encoded');
|
|
237
|
+
}
|
|
238
|
+
}),
|
|
239
|
+
audit('BF61', 'MUST accept utf-8 encoded request', async () => {
|
|
240
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: {
|
|
243
|
+
'content-type': 'application/json; charset=utf-8',
|
|
244
|
+
},
|
|
245
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
246
|
+
});
|
|
247
|
+
ressert(res).status.toBe(200);
|
|
248
|
+
}),
|
|
249
|
+
audit('78D5', 'MUST assume utf-8 in request if encoding is unspecified', async () => {
|
|
250
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: {
|
|
253
|
+
'content-type': 'application/json',
|
|
254
|
+
},
|
|
255
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
256
|
+
});
|
|
257
|
+
ressert(res).status.toBe(200);
|
|
258
|
+
}),
|
|
259
|
+
// Request
|
|
260
|
+
audit('2C94', 'MUST accept POST requests', async () => {
|
|
261
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: { 'content-type': 'application/json' },
|
|
264
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
265
|
+
});
|
|
266
|
+
ressert(res).status.toBe(200);
|
|
267
|
+
}),
|
|
268
|
+
audit('5A70', 'MAY accept application/x-www-form-urlencoded formatted GET requests', async () => {
|
|
269
|
+
const url = new URL(await getUrl(opts.url));
|
|
270
|
+
url.searchParams.set('query', '{ __typename }');
|
|
271
|
+
const res = await fetchFn(url.toString());
|
|
272
|
+
ressert(res).status.toBe(200);
|
|
273
|
+
}),
|
|
274
|
+
// Request GET
|
|
275
|
+
// TODO: this is a MUST if the server supports GET requests
|
|
276
|
+
audit('9C48', 'MAY NOT allow executing mutations on GET requests', async () => {
|
|
277
|
+
const url = new URL(await getUrl(opts.url));
|
|
278
|
+
url.searchParams.set('query', 'mutation { __typename }');
|
|
279
|
+
const res = await fetchFn(url.toString(), {
|
|
280
|
+
headers: {
|
|
281
|
+
accept: 'application/graphql-response+json',
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
ressert(res).status.toBeBetween(400, 499);
|
|
285
|
+
}),
|
|
286
|
+
// Request POST
|
|
287
|
+
audit('9ABE', 'SHOULD respond with 4xx status code if content-type is not supplied on POST requests', async () => {
|
|
288
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
});
|
|
291
|
+
ressert(res).status.toBeBetween(400, 499);
|
|
292
|
+
}),
|
|
293
|
+
audit('03D4', 'MUST accept application/json POST requests', async () => {
|
|
294
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { 'content-type': 'application/json' },
|
|
297
|
+
body: JSON.stringify({ query: '{ __typename }' }),
|
|
298
|
+
});
|
|
299
|
+
ressert(res).status.toBe(200);
|
|
300
|
+
}),
|
|
301
|
+
audit('7267', 'MUST require a request body on POST', async () => {
|
|
302
|
+
var _a;
|
|
303
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: { 'content-type': 'application/json' },
|
|
306
|
+
});
|
|
307
|
+
if ((_a = res.headers.get('content-type')) === null || _a === void 0 ? void 0 : _a.includes('application/json')) {
|
|
308
|
+
await ressert(res).bodyAsExecutionResult.toHaveProperty('errors');
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
ressert(res).status.toBe(400);
|
|
312
|
+
}
|
|
313
|
+
}),
|
|
314
|
+
// Request Parameters
|
|
315
|
+
audit(
|
|
316
|
+
// TODO: convert to MUST after watershed
|
|
317
|
+
'6610', 'SHOULD use 400 status code on missing {query} parameter when accepting application/graphql-response+json', async () => {
|
|
318
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
319
|
+
method: 'POST',
|
|
320
|
+
headers: {
|
|
321
|
+
'content-type': 'application/json',
|
|
322
|
+
accept: 'application/graphql-response+json',
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({ notquery: '{ __typename }' }),
|
|
325
|
+
});
|
|
326
|
+
ressert(res).status.toBe(400);
|
|
327
|
+
}),
|
|
328
|
+
audit('3715', 'SHOULD use 200 status code with errors field on missing {query} parameter when accepting application/json', async () => {
|
|
329
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: {
|
|
332
|
+
'content-type': 'application/json',
|
|
333
|
+
accept: 'application/json',
|
|
334
|
+
},
|
|
335
|
+
body: JSON.stringify({ notquery: '{ __typename }' }),
|
|
336
|
+
});
|
|
337
|
+
ressert(res).status.toBe(200);
|
|
338
|
+
await ressert(res).bodyAsExecutionResult.toHaveProperty('errors');
|
|
339
|
+
}),
|
|
340
|
+
...[{ obj: 'ect' }, 0, false, ['array']].map((invalid, index) => audit(`4F5${index}`,
|
|
341
|
+
// TODO: convert to MUST after watershed
|
|
342
|
+
`SHOULD use 400 status code on ${extendedTypeof(invalid)} {query} parameter when accepting application/graphql-response+json`, async () => {
|
|
343
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers: {
|
|
346
|
+
'content-type': 'application/json',
|
|
347
|
+
accept: 'application/graphql-response+json',
|
|
348
|
+
},
|
|
349
|
+
body: JSON.stringify({
|
|
350
|
+
query: invalid,
|
|
351
|
+
}),
|
|
352
|
+
});
|
|
353
|
+
ressert(res).status.toBe(400);
|
|
354
|
+
})),
|
|
355
|
+
...[{ obj: 'ect' }, 0, false, ['array']].map((invalid, index) => audit(`9FE${index}`, `SHOULD use 200 status code with errors field on ${extendedTypeof(invalid)} {query} parameter when accepting application/json`, async () => {
|
|
356
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
357
|
+
method: 'POST',
|
|
358
|
+
headers: {
|
|
359
|
+
'content-type': 'application/json',
|
|
360
|
+
accept: 'application/json',
|
|
361
|
+
},
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
query: invalid,
|
|
364
|
+
}),
|
|
365
|
+
});
|
|
366
|
+
ressert(res).status.toBe(200);
|
|
367
|
+
await ressert(res).bodyAsExecutionResult.toHaveProperty('errors');
|
|
368
|
+
})),
|
|
369
|
+
audit(
|
|
370
|
+
// TODO: convert to MUST after watershed
|
|
371
|
+
'34A2', 'SHOULD allow string {query} parameter when accepting application/graphql-response+json', async () => {
|
|
372
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
headers: {
|
|
375
|
+
'content-type': 'application/json',
|
|
376
|
+
accept: 'application/graphql-response+json',
|
|
377
|
+
},
|
|
378
|
+
body: JSON.stringify({
|
|
379
|
+
query: '{ __typename }',
|
|
380
|
+
}),
|
|
381
|
+
});
|
|
382
|
+
ressert(res).status.toBe(200);
|
|
383
|
+
}),
|
|
384
|
+
audit('13EE', 'MUST allow string {query} parameter when accepting application/json', async () => {
|
|
385
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
headers: {
|
|
388
|
+
'content-type': 'application/json',
|
|
389
|
+
accept: 'application/json',
|
|
390
|
+
},
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
query: '{ __typename }',
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
ressert(res).status.toBe(200);
|
|
396
|
+
await ressert(res).bodyAsExecutionResult.notToHaveProperty('errors');
|
|
397
|
+
}),
|
|
398
|
+
...[{ obj: 'ect' }, 0, false, ['array']].map((invalid, index) => audit(`E3E${index}`,
|
|
399
|
+
// TODO: convert to MUST after watershed
|
|
400
|
+
`SHOULD use 400 status code on ${extendedTypeof(invalid)} {operationName} parameter when accepting application/graphql-response+json`, async () => {
|
|
401
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: {
|
|
404
|
+
'content-type': 'application/json',
|
|
405
|
+
accept: 'application/graphql-response+json',
|
|
406
|
+
},
|
|
407
|
+
body: JSON.stringify({
|
|
408
|
+
operationName: invalid,
|
|
409
|
+
query: '{ __typename }',
|
|
410
|
+
}),
|
|
411
|
+
});
|
|
412
|
+
ressert(res).status.toBe(400);
|
|
413
|
+
})),
|
|
414
|
+
...[{ obj: 'ect' }, 0, false, ['array']].map((invalid, index) => audit(`FB9${index}`, `SHOULD use 200 status code with errors field on ${extendedTypeof(invalid)} {operationName} parameter when accepting application/json`, async () => {
|
|
415
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: {
|
|
418
|
+
'content-type': 'application/json',
|
|
419
|
+
accept: 'application/json',
|
|
420
|
+
},
|
|
421
|
+
body: JSON.stringify({
|
|
422
|
+
operationName: invalid,
|
|
423
|
+
query: '{ __typename }',
|
|
424
|
+
}),
|
|
425
|
+
});
|
|
426
|
+
ressert(res).status.toBe(200);
|
|
427
|
+
await ressert(res).bodyAsExecutionResult.toHaveProperty('errors');
|
|
428
|
+
})),
|
|
429
|
+
audit(
|
|
430
|
+
// TODO: convert to MUST after watershed
|
|
431
|
+
'8161', 'SHOULD allow string {operationName} parameter when accepting application/graphql-response+json', async () => {
|
|
432
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
433
|
+
method: 'POST',
|
|
434
|
+
headers: {
|
|
435
|
+
'content-type': 'application/json',
|
|
436
|
+
accept: 'application/graphql-response+json',
|
|
437
|
+
},
|
|
438
|
+
body: JSON.stringify({
|
|
439
|
+
operationName: 'Query',
|
|
440
|
+
query: 'query Query { __typename }',
|
|
441
|
+
}),
|
|
442
|
+
});
|
|
443
|
+
ressert(res).status.toBe(200);
|
|
444
|
+
}),
|
|
445
|
+
audit('B8B3', 'MUST allow string {operationName} parameter when accepting application/json', async () => {
|
|
446
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
447
|
+
method: 'POST',
|
|
448
|
+
headers: {
|
|
449
|
+
'content-type': 'application/json',
|
|
450
|
+
accept: 'application/json',
|
|
451
|
+
},
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
operationName: 'Query',
|
|
454
|
+
query: 'query Query { __typename }',
|
|
455
|
+
}),
|
|
456
|
+
});
|
|
457
|
+
ressert(res).status.toBe(200);
|
|
458
|
+
await ressert(res).bodyAsExecutionResult.notToHaveProperty('errors');
|
|
459
|
+
}),
|
|
460
|
+
...['variables', 'operationName', 'extensions'].flatMap((parameter, index) => [
|
|
461
|
+
audit(`94B${index}`,
|
|
462
|
+
// TODO: convert to MUST after watershed
|
|
463
|
+
`SHOULD allow null {${parameter}} parameter when accepting application/graphql-response+json`, async () => {
|
|
464
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: {
|
|
467
|
+
'content-type': 'application/json',
|
|
468
|
+
accept: 'application/graphql-response+json',
|
|
469
|
+
},
|
|
470
|
+
body: JSON.stringify({
|
|
471
|
+
query: '{ __typename }',
|
|
472
|
+
[parameter]: null,
|
|
473
|
+
}),
|
|
474
|
+
});
|
|
475
|
+
ressert(res).status.toBe(200);
|
|
476
|
+
await ressert(res).bodyAsExecutionResult.notToHaveProperty('errors');
|
|
477
|
+
}),
|
|
478
|
+
audit(`022${index}`, `MUST allow null {${parameter}} parameter when accepting application/json`, async () => {
|
|
479
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
480
|
+
method: 'POST',
|
|
481
|
+
headers: {
|
|
482
|
+
'content-type': 'application/json',
|
|
483
|
+
accept: 'application/json',
|
|
484
|
+
},
|
|
485
|
+
body: JSON.stringify({
|
|
486
|
+
query: '{ __typename }',
|
|
487
|
+
[parameter]: null,
|
|
488
|
+
}),
|
|
489
|
+
});
|
|
490
|
+
ressert(res).status.toBe(200);
|
|
491
|
+
await ressert(res).bodyAsExecutionResult.notToHaveProperty('errors');
|
|
492
|
+
}),
|
|
493
|
+
]),
|
|
494
|
+
...['string', 0, false, ['array']].map((invalid, index) => audit(`69B${index}`,
|
|
495
|
+
// TODO: convert to MUST after watershed
|
|
496
|
+
`SHOULD use 400 status code on ${extendedTypeof(invalid)} {variables} parameter when accepting application/graphql-response+json`, async () => {
|
|
497
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
498
|
+
method: 'POST',
|
|
499
|
+
headers: {
|
|
500
|
+
'content-type': 'application/json',
|
|
501
|
+
accept: 'application/graphql-response+json',
|
|
502
|
+
},
|
|
503
|
+
body: JSON.stringify({
|
|
504
|
+
query: '{ __typename }',
|
|
505
|
+
variables: invalid,
|
|
506
|
+
}),
|
|
507
|
+
});
|
|
508
|
+
ressert(res).status.toBe(400);
|
|
509
|
+
})),
|
|
510
|
+
...['string', 0, false, ['array']].map((invalid, index) => audit(`F05${index}`, `SHOULD use 200 status code with errors field on ${extendedTypeof(invalid)} {variables} parameter when accepting application/json`, async () => {
|
|
511
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
512
|
+
method: 'POST',
|
|
513
|
+
headers: {
|
|
514
|
+
'content-type': 'application/json',
|
|
515
|
+
accept: 'application/json',
|
|
516
|
+
},
|
|
517
|
+
body: JSON.stringify({
|
|
518
|
+
query: '{ __typename }',
|
|
519
|
+
variables: invalid,
|
|
520
|
+
}),
|
|
521
|
+
});
|
|
522
|
+
ressert(res).status.toBe(200);
|
|
523
|
+
await ressert(res).bodyAsExecutionResult.toHaveProperty('errors');
|
|
524
|
+
})),
|
|
525
|
+
audit(
|
|
526
|
+
// TODO: convert to MUST after watershed
|
|
527
|
+
'2EA1', 'SHOULD allow map {variables} parameter when accepting application/graphql-response+json', async () => {
|
|
528
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: {
|
|
531
|
+
'content-type': 'application/json',
|
|
532
|
+
accept: 'application/graphql-response+json',
|
|
533
|
+
},
|
|
534
|
+
body: JSON.stringify({
|
|
535
|
+
query: 'query Type($name: String!) { __type(name: $name) { name } }',
|
|
536
|
+
variables: { name: 'sometype' },
|
|
537
|
+
}),
|
|
538
|
+
});
|
|
539
|
+
ressert(res).status.toBe(200);
|
|
540
|
+
}),
|
|
541
|
+
audit('28B9', 'MUST allow map {variables} parameter when accepting application/json', async () => {
|
|
542
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: {
|
|
545
|
+
'content-type': 'application/json',
|
|
546
|
+
accept: 'application/json',
|
|
547
|
+
},
|
|
548
|
+
body: JSON.stringify({
|
|
549
|
+
query: 'query Type($name: String!) { __type(name: $name) { name } }',
|
|
550
|
+
variables: { name: 'sometype' },
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
ressert(res).status.toBe(200);
|
|
554
|
+
await ressert(res).bodyAsExecutionResult.notToHaveProperty('errors');
|
|
555
|
+
}),
|
|
556
|
+
audit('D6D5', 'MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/graphql-response+json', async () => {
|
|
557
|
+
const url = new URL(await getUrl(opts.url));
|
|
558
|
+
url.searchParams.set('query', 'query Type($name: String!) { __type(name: $name) { name } }');
|
|
559
|
+
url.searchParams.set('variables', JSON.stringify({ name: 'sometype' }));
|
|
560
|
+
const res = await fetchFn(url.toString(), {
|
|
561
|
+
method: 'GET',
|
|
562
|
+
headers: {
|
|
563
|
+
accept: 'application/graphql-response+json',
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
ressert(res).status.toBe(200);
|
|
567
|
+
}),
|
|
568
|
+
audit('6A70', 'MAY allow URL-encoded JSON string {variables} parameter in GETs when accepting application/json', async () => {
|
|
569
|
+
const url = new URL(await getUrl(opts.url));
|
|
570
|
+
url.searchParams.set('query', 'query Type($name: String!) { __type(name: $name) { name } }');
|
|
571
|
+
url.searchParams.set('variables', JSON.stringify({ name: 'sometype' }));
|
|
572
|
+
const res = await fetchFn(url.toString(), {
|
|
573
|
+
method: 'GET',
|
|
574
|
+
headers: {
|
|
575
|
+
accept: 'application/json',
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
ressert(res).status.toBe(200);
|
|
579
|
+
await ressert(res).bodyAsExecutionResult.notToHaveProperty('errors');
|
|
580
|
+
}),
|
|
581
|
+
...['string', 0, false, ['array']].map((invalid, index) => audit(`904${index}`,
|
|
582
|
+
// TODO: convert to MUST after watershed
|
|
583
|
+
`SHOULD use 400 status code on ${extendedTypeof(invalid)} {extensions} parameter when accepting application/graphql-response+json`, async () => {
|
|
584
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
585
|
+
method: 'POST',
|
|
586
|
+
headers: {
|
|
587
|
+
'content-type': 'application/json',
|
|
588
|
+
accept: 'application/graphql-response+json',
|
|
589
|
+
},
|
|
590
|
+
body: JSON.stringify({
|
|
591
|
+
query: '{ __typename }',
|
|
592
|
+
extensions: invalid,
|
|
593
|
+
}),
|
|
594
|
+
});
|
|
595
|
+
ressert(res).status.toBe(400);
|
|
596
|
+
})),
|
|
597
|
+
...['string', 0, false, ['array']].map((invalid, index) => audit(`368${index}`, `SHOULD use 200 status code with errors field on ${extendedTypeof(invalid)} {extensions} parameter when accepting application/json`, async () => {
|
|
598
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
599
|
+
method: 'POST',
|
|
600
|
+
headers: {
|
|
601
|
+
'content-type': 'application/json',
|
|
602
|
+
accept: 'application/json',
|
|
603
|
+
},
|
|
604
|
+
body: JSON.stringify({
|
|
605
|
+
query: '{ __typename }',
|
|
606
|
+
extensions: invalid,
|
|
607
|
+
}),
|
|
608
|
+
});
|
|
609
|
+
ressert(res).status.toBe(200);
|
|
610
|
+
await ressert(res).bodyAsExecutionResult.toHaveProperty('errors');
|
|
611
|
+
})),
|
|
612
|
+
audit(
|
|
613
|
+
// TODO: convert to MUST after watershed
|
|
614
|
+
'428F', 'SHOULD allow map {extensions} parameter when accepting application/graphql-response+json', async () => {
|
|
615
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
616
|
+
method: 'POST',
|
|
617
|
+
headers: {
|
|
618
|
+
'content-type': 'application/json',
|
|
619
|
+
accept: 'application/graphql-response+json',
|
|
620
|
+
},
|
|
621
|
+
body: JSON.stringify({
|
|
622
|
+
query: '{ __typename }',
|
|
623
|
+
extensions: { some: 'value' },
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
ressert(res).status.toBe(200);
|
|
627
|
+
}),
|
|
628
|
+
audit('1B7A', 'MUST allow map {extensions} parameter when accepting application/json', async () => {
|
|
629
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
630
|
+
method: 'POST',
|
|
631
|
+
headers: {
|
|
632
|
+
'content-type': 'application/json',
|
|
633
|
+
accept: 'application/json',
|
|
634
|
+
},
|
|
635
|
+
body: JSON.stringify({
|
|
636
|
+
query: '{ __typename }',
|
|
637
|
+
extensions: { some: 'value' },
|
|
638
|
+
}),
|
|
639
|
+
});
|
|
640
|
+
ressert(res).status.toBe(200);
|
|
641
|
+
await ressert(res).bodyAsExecutionResult.notToHaveProperty('errors');
|
|
642
|
+
}),
|
|
643
|
+
// TODO: audit('39AA', 'MUST accept a map for the {extensions} parameter'),
|
|
644
|
+
// Response application/json
|
|
645
|
+
audit('D477', 'SHOULD use 200 status code on JSON parsing failure when accepting application/json', async () => {
|
|
646
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
647
|
+
method: 'POST',
|
|
648
|
+
headers: {
|
|
649
|
+
'content-type': 'application/json',
|
|
650
|
+
accept: 'application/json',
|
|
651
|
+
},
|
|
652
|
+
body: '{ "not a JSON',
|
|
653
|
+
});
|
|
654
|
+
ressert(res).status.toBe(200);
|
|
655
|
+
}),
|
|
656
|
+
audit('F5AF', 'SHOULD use 200 status code if parameters are invalid when accepting application/json', async () => {
|
|
657
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
658
|
+
method: 'POST',
|
|
659
|
+
headers: {
|
|
660
|
+
'content-type': 'application/json',
|
|
661
|
+
accept: 'application/json',
|
|
662
|
+
},
|
|
663
|
+
body: JSON.stringify({
|
|
664
|
+
qeury: /* typo */ '{ __typename }',
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
ressert(res).status.toBe(200);
|
|
668
|
+
}),
|
|
669
|
+
audit('572B', 'SHOULD use 200 status code on document parsing failure when accepting application/json', async () => {
|
|
670
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
671
|
+
method: 'POST',
|
|
672
|
+
headers: {
|
|
673
|
+
'content-type': 'application/json',
|
|
674
|
+
accept: 'application/json',
|
|
675
|
+
},
|
|
676
|
+
body: JSON.stringify({ query: '{' }),
|
|
677
|
+
});
|
|
678
|
+
ressert(res).status.toBe(200);
|
|
679
|
+
}),
|
|
680
|
+
audit('FDE2', 'SHOULD use 200 status code on document validation failure when accepting application/json', async () => {
|
|
681
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
682
|
+
method: 'POST',
|
|
683
|
+
headers: {
|
|
684
|
+
'content-type': 'application/json',
|
|
685
|
+
accept: 'application/json',
|
|
686
|
+
},
|
|
687
|
+
body: JSON.stringify({
|
|
688
|
+
query: '{ 8f31403dfe404bccbb0e835f2629c6a7 }', // making sure the field doesnt exist
|
|
689
|
+
}),
|
|
690
|
+
});
|
|
691
|
+
ressert(res).status.toBe(200);
|
|
692
|
+
}),
|
|
693
|
+
// Response application/graphql-response+json
|
|
694
|
+
audit(
|
|
695
|
+
// TODO: convert to MUST after watershed
|
|
696
|
+
'60AA', 'SHOULD use 4xx or 5xx status codes on JSON parsing failure when accepting application/graphql-response+json', async () => {
|
|
697
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
698
|
+
method: 'POST',
|
|
699
|
+
headers: {
|
|
700
|
+
'content-type': 'application/json',
|
|
701
|
+
accept: 'application/graphql-response+json',
|
|
702
|
+
},
|
|
703
|
+
body: '{ "not a JSON',
|
|
704
|
+
});
|
|
705
|
+
ressert(res).status.toBeBetween(400, 499);
|
|
706
|
+
}),
|
|
707
|
+
audit('2163', 'SHOULD use 400 status code on JSON parsing failure when accepting application/graphql-response+json', async () => {
|
|
708
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
709
|
+
method: 'POST',
|
|
710
|
+
headers: {
|
|
711
|
+
'content-type': 'application/json',
|
|
712
|
+
accept: 'application/graphql-response+json',
|
|
713
|
+
},
|
|
714
|
+
body: '{ "not a JSON',
|
|
715
|
+
});
|
|
716
|
+
ressert(res).status.toBe(400);
|
|
717
|
+
}),
|
|
718
|
+
audit(
|
|
719
|
+
// TODO: convert to MUST after watershed
|
|
720
|
+
'3E36', 'SHOULD use 4xx or 5xx status codes if parameters are invalid when accepting application/graphql-response+json', async () => {
|
|
721
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
722
|
+
method: 'POST',
|
|
723
|
+
headers: {
|
|
724
|
+
'content-type': 'application/json',
|
|
725
|
+
accept: 'application/graphql-response+json',
|
|
726
|
+
},
|
|
727
|
+
body: JSON.stringify({
|
|
728
|
+
qeury /* typo */: '{ __typename }',
|
|
729
|
+
}),
|
|
730
|
+
});
|
|
731
|
+
ressert(res).status.toBeBetween(400, 599);
|
|
732
|
+
}),
|
|
733
|
+
audit('17C5', 'SHOULD use 400 status code if parameters are invalid when accepting application/graphql-response+json', async () => {
|
|
734
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
735
|
+
method: 'POST',
|
|
736
|
+
headers: {
|
|
737
|
+
'content-type': 'application/json',
|
|
738
|
+
accept: 'application/graphql-response+json',
|
|
739
|
+
},
|
|
740
|
+
body: JSON.stringify({
|
|
741
|
+
qeury: /* typo */ '{ __typename }',
|
|
742
|
+
}),
|
|
743
|
+
});
|
|
744
|
+
ressert(res).status.toBe(400);
|
|
745
|
+
}),
|
|
746
|
+
audit('34D6', 'SHOULD not contain the data entry if parameters are invalid when accepting application/graphql-response+json', async () => {
|
|
747
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
748
|
+
method: 'POST',
|
|
749
|
+
headers: {
|
|
750
|
+
'content-type': 'application/json',
|
|
751
|
+
accept: 'application/graphql-response+json',
|
|
752
|
+
},
|
|
753
|
+
body: JSON.stringify({
|
|
754
|
+
qeury: /* typo */ '{ __typename }',
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
await ressert(res).bodyAsExecutionResult.data.toBe(undefined);
|
|
758
|
+
}),
|
|
759
|
+
audit(
|
|
760
|
+
// TODO: convert to MUST after watershed
|
|
761
|
+
'865D', 'SHOULD use 4xx or 5xx status codes on document parsing failure when accepting application/graphql-response+json', async () => {
|
|
762
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
763
|
+
method: 'POST',
|
|
764
|
+
headers: {
|
|
765
|
+
'content-type': 'application/json',
|
|
766
|
+
accept: 'application/graphql-response+json',
|
|
767
|
+
},
|
|
768
|
+
body: JSON.stringify({
|
|
769
|
+
query: '{',
|
|
770
|
+
}),
|
|
771
|
+
});
|
|
772
|
+
ressert(res).status.toBeBetween(400, 599);
|
|
773
|
+
}),
|
|
774
|
+
audit('556A', 'SHOULD use 400 status code on document parsing failure when accepting application/graphql-response+json', async () => {
|
|
775
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
776
|
+
method: 'POST',
|
|
777
|
+
headers: {
|
|
778
|
+
'content-type': 'application/json',
|
|
779
|
+
accept: 'application/graphql-response+json',
|
|
780
|
+
},
|
|
781
|
+
body: JSON.stringify({
|
|
782
|
+
query: '{',
|
|
783
|
+
}),
|
|
784
|
+
});
|
|
785
|
+
ressert(res).status.toBe(400);
|
|
786
|
+
}),
|
|
787
|
+
audit('D586', 'SHOULD not contain the data entry on document parsing failure when accepting application/graphql-response+json', async () => {
|
|
788
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
789
|
+
method: 'POST',
|
|
790
|
+
headers: {
|
|
791
|
+
'content-type': 'application/json',
|
|
792
|
+
accept: 'application/graphql-response+json',
|
|
793
|
+
},
|
|
794
|
+
body: JSON.stringify({
|
|
795
|
+
query: '{',
|
|
796
|
+
}),
|
|
797
|
+
});
|
|
798
|
+
await ressert(res).bodyAsExecutionResult.data.toBe(undefined);
|
|
799
|
+
}),
|
|
800
|
+
audit(
|
|
801
|
+
// TODO: convert to MUST after watershed
|
|
802
|
+
'51FE', 'SHOULD use 4xx or 5xx status codes on document validation failure when accepting application/graphql-response+json', async () => {
|
|
803
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
804
|
+
method: 'POST',
|
|
805
|
+
headers: {
|
|
806
|
+
'content-type': 'application/json',
|
|
807
|
+
accept: 'application/graphql-response+json',
|
|
808
|
+
},
|
|
809
|
+
body: JSON.stringify({
|
|
810
|
+
query: '{ 8f31403dfe404bccbb0e835f2629c6a7 }', // making sure the field doesnt exist
|
|
811
|
+
}),
|
|
812
|
+
});
|
|
813
|
+
ressert(res).status.toBeBetween(400, 599);
|
|
814
|
+
}),
|
|
815
|
+
audit('74FF', 'SHOULD use 400 status code on document validation failure when accepting application/graphql-response+json', async () => {
|
|
816
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
817
|
+
method: 'POST',
|
|
818
|
+
headers: {
|
|
819
|
+
'content-type': 'application/json',
|
|
820
|
+
accept: 'application/graphql-response+json',
|
|
821
|
+
},
|
|
822
|
+
body: JSON.stringify({
|
|
823
|
+
query: '{ 8f31403dfe404bccbb0e835f2629c6a7 }', // making sure the field doesnt exist
|
|
824
|
+
}),
|
|
825
|
+
});
|
|
826
|
+
ressert(res).status.toBe(400);
|
|
827
|
+
}),
|
|
828
|
+
audit('5E5B', 'SHOULD not contain the data entry on document validation failure when accepting application/graphql-response+json', async () => {
|
|
829
|
+
const res = await fetchFn(await getUrl(opts.url), {
|
|
830
|
+
method: 'POST',
|
|
831
|
+
headers: {
|
|
832
|
+
'content-type': 'application/json',
|
|
833
|
+
accept: 'application/graphql-response+json',
|
|
834
|
+
},
|
|
835
|
+
body: JSON.stringify({
|
|
836
|
+
query: '{ 8f31403dfe404bccbb0e835f2629c6a7 }', // making sure the field doesnt exist
|
|
837
|
+
}),
|
|
838
|
+
});
|
|
839
|
+
await ressert(res).bodyAsExecutionResult.data.toBe(undefined);
|
|
840
|
+
}),
|
|
841
|
+
// TODO: how to fail and have the data entry?
|
|
842
|
+
// audit('EE52', 'MUST use 2xx status code if response contains the data entry and it is not null when accepting application/graphql-response+json'),
|
|
843
|
+
// TODO: how to make an unauthorized request?
|
|
844
|
+
// https://graphql.github.io/graphql-over-http/draft/#sel-EANNNDTAAEVBAAqqc
|
|
845
|
+
// audit('BC58', 'SHOULD use 401 or 403 status codes when the request is not permitted')
|
|
846
|
+
];
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Performs the full list of server audits required for GraphQL over HTTP spec conformance.
|
|
850
|
+
*
|
|
851
|
+
* Please consult the `AuditResult` for more information.
|
|
852
|
+
*
|
|
853
|
+
* @category Audits
|
|
854
|
+
*/
|
|
855
|
+
async function auditServer(opts) {
|
|
856
|
+
const audits = serverAudits(opts);
|
|
857
|
+
// audit tests will throw only on fatal errors, tests are contained within the AuditResult
|
|
858
|
+
return await Promise.all(audits.map(({ fn }) => fn()));
|
|
859
|
+
}
|
|
860
|
+
/** @private */
|
|
861
|
+
async function getUrl(url) {
|
|
862
|
+
if (typeof url === 'function') {
|
|
863
|
+
return await url();
|
|
864
|
+
}
|
|
865
|
+
return url;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Renders the provided audit results to well-formatted and valid HTML.
|
|
870
|
+
*
|
|
871
|
+
* Do note that the rendered result is not an HTML document, it's rather
|
|
872
|
+
* just a component with results.
|
|
873
|
+
*/
|
|
874
|
+
async function renderAuditResultsToHTML(results) {
|
|
875
|
+
const grouped = {
|
|
876
|
+
total: 0,
|
|
877
|
+
ok: [],
|
|
878
|
+
warn: [],
|
|
879
|
+
error: [],
|
|
880
|
+
};
|
|
881
|
+
for (const result of results) {
|
|
882
|
+
grouped.total++;
|
|
883
|
+
if (result.status === 'ok') {
|
|
884
|
+
grouped[result.status].push(result);
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
grouped[result.status].push(result);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
let report = '<i>* This report was auto-generated by graphql-http</i>\n';
|
|
891
|
+
report += '\n';
|
|
892
|
+
report += '<h1>GraphQL over HTTP audit report</h1>\n';
|
|
893
|
+
report += '\n';
|
|
894
|
+
report += '<ul>\n';
|
|
895
|
+
report += `<li><b>${grouped.total}</b> audits in total</li>\n`;
|
|
896
|
+
// font-family: monospace helps render native emojis in HTML
|
|
897
|
+
if (grouped.ok.length) {
|
|
898
|
+
report += `<li><span style="font-family: monospace">✅</span> <b>${grouped.ok.length}</b> pass</li>\n`;
|
|
899
|
+
}
|
|
900
|
+
if (grouped.warn.length) {
|
|
901
|
+
// TODO: warning sign is rendered as "⚠️" in markdown instead of the emoji
|
|
902
|
+
report += `<li><span style="font-family: monospace">⚠️</span> <b>${grouped.warn.length}</b> warnings (optional)</li>\n`;
|
|
903
|
+
}
|
|
904
|
+
if (grouped.error.length) {
|
|
905
|
+
report += `<li><span style="font-family: monospace">❌</span> <b>${grouped.error.length}</b> errors (required)</li>\n`;
|
|
906
|
+
}
|
|
907
|
+
report += '</ul>\n';
|
|
908
|
+
report += '\n';
|
|
909
|
+
if (grouped.ok.length) {
|
|
910
|
+
report += '<h2>Passing</h2>\n';
|
|
911
|
+
report += '<ol>\n';
|
|
912
|
+
for (const [, result] of grouped.ok.entries()) {
|
|
913
|
+
report += `<li><code>${result.id}</code> ${result.name}</li>\n`;
|
|
914
|
+
}
|
|
915
|
+
report += '</ol>\n';
|
|
916
|
+
report += '\n';
|
|
917
|
+
}
|
|
918
|
+
if (grouped.warn.length) {
|
|
919
|
+
report += `<h2>Warnings</h2>\n`;
|
|
920
|
+
report += 'The server <i>SHOULD</i> support these, but is not required.\n';
|
|
921
|
+
report += '<ol>\n';
|
|
922
|
+
for (const [, result] of grouped.warn.entries()) {
|
|
923
|
+
report += await printAuditFail(result);
|
|
924
|
+
}
|
|
925
|
+
report += '</ol>\n';
|
|
926
|
+
report += '\n';
|
|
927
|
+
}
|
|
928
|
+
if (grouped.error.length) {
|
|
929
|
+
report += `<h2>Errors</h2>\n`;
|
|
930
|
+
report += 'The server <b>MUST</b> support these.\n';
|
|
931
|
+
report += '<ol>\n';
|
|
932
|
+
for (const [, result] of grouped.error.entries()) {
|
|
933
|
+
report += await printAuditFail(result);
|
|
934
|
+
}
|
|
935
|
+
report += '</ol>\n';
|
|
936
|
+
}
|
|
937
|
+
return report;
|
|
938
|
+
}
|
|
939
|
+
async function printAuditFail(result) {
|
|
940
|
+
let report = '';
|
|
941
|
+
report += `<li><code>${result.id}</code> ${result.name}\n`;
|
|
942
|
+
report += '<details>\n';
|
|
943
|
+
report += `<summary>${truncate(result.reason)}</summary>\n`;
|
|
944
|
+
report += '<pre><code class="lang-json">'; // no "\n" because they count in HTML pre tags
|
|
945
|
+
const res = result.response;
|
|
946
|
+
const headers = {};
|
|
947
|
+
for (const [key, val] of res.headers.entries()) {
|
|
948
|
+
// some headers change on each run, dont report it
|
|
949
|
+
if (key === 'date') {
|
|
950
|
+
headers[key] = '<timestamp>';
|
|
951
|
+
}
|
|
952
|
+
else if (['cf-ray', 'server-timing'].includes(key)) {
|
|
953
|
+
headers[key] = '<omitted>';
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
headers[key] = val;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
let text = '', json;
|
|
960
|
+
try {
|
|
961
|
+
text = await res.text();
|
|
962
|
+
json = JSON.parse(text);
|
|
963
|
+
}
|
|
964
|
+
catch (_a) {
|
|
965
|
+
// noop
|
|
966
|
+
}
|
|
967
|
+
const stringified = JSON.stringify({
|
|
968
|
+
status: res.status,
|
|
969
|
+
statusText: res.statusText,
|
|
970
|
+
headers,
|
|
971
|
+
body: json || ((text === null || text === void 0 ? void 0 : text.length) > 5120 ? '<body is too long>' : text) || null,
|
|
972
|
+
}, (_k, v) => {
|
|
973
|
+
if (v != null && typeof v === 'object' && !Array.isArray(v)) {
|
|
974
|
+
// sort object fields for stable stringify
|
|
975
|
+
const acc = {};
|
|
976
|
+
return Object.keys(v)
|
|
977
|
+
.sort()
|
|
978
|
+
.reverse() // body on bottom
|
|
979
|
+
.reduce((acc, k) => {
|
|
980
|
+
acc[k] = v[k];
|
|
981
|
+
return acc;
|
|
982
|
+
}, acc);
|
|
983
|
+
}
|
|
984
|
+
return v;
|
|
985
|
+
}, 2);
|
|
986
|
+
report += stringified + '\n';
|
|
987
|
+
report += '</code></pre>\n';
|
|
988
|
+
report += '</details>\n';
|
|
989
|
+
report += '</li>\n';
|
|
990
|
+
return report;
|
|
991
|
+
}
|
|
992
|
+
function truncate(str, len = 1024) {
|
|
993
|
+
if (str.length > len) {
|
|
994
|
+
return str.substring(0, len) + '...';
|
|
995
|
+
}
|
|
996
|
+
return str;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
exports.auditServer = auditServer;
|
|
1000
|
+
exports.renderAuditResultsToHTML = renderAuditResultsToHTML;
|
|
1001
|
+
exports.serverAudits = serverAudits;
|
|
1002
|
+
|
|
1003
|
+
}));
|