@quantcdn/edge-conformance 0.0.0-stage → 0.1.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/src/index.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The edge-conformance runner.
3
+ * It loads the JSON vectors, gives each case to an adapter and compares the
4
+ * adapter's answer with the case's expected values.
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ export const VECTORS_DIR = fileURLToPath(new URL('../vectors/', import.meta.url));
12
+
13
+ /** The suite tags. Each case has exactly one; it names the vector directory. */
14
+ export const SUITE_TAGS = ['rules', 'static', 'functions'];
15
+
16
+ /** Every tag that a case can have. */
17
+ export const TAGS = [...SUITE_TAGS, 'unverified'];
18
+
19
+ /** The adapter operations, and the arguments that each one gets from a case input. */
20
+ export const OPS = {
21
+ matchRule: (input) => [input.rules, input.functions || [], input.request],
22
+ serveStatic: (input) => [input.files, input.request],
23
+ runFunction: (input) => [input],
24
+ serveSite: (input) => [input]
25
+ };
26
+
27
+ /** The arguments that the runner passes to adapter[op] for a case input. */
28
+ export function adapterArgs(op, input) {
29
+ return OPS[op](input);
30
+ }
31
+
32
+ async function vectorFiles(dir) {
33
+ const entries = await fs.readdir(dir, { withFileTypes: true });
34
+ const nested = await Promise.all(entries
35
+ .sort((a, b) => a.name.localeCompare(b.name))
36
+ .map((entry) => {
37
+ const full = path.join(dir, entry.name);
38
+ if (entry.isDirectory()) return vectorFiles(full);
39
+ return entry.name.endsWith('.json') ? [full] : [];
40
+ }));
41
+ return nested.flat();
42
+ }
43
+
44
+ async function readVectorFile(file, dir) {
45
+ const suite = path.relative(dir, file).split(path.sep)[0];
46
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
47
+ return data.cases.map((item) => ({ ...item, suite, file: path.relative(dir, file) }));
48
+ }
49
+
50
+ /**
51
+ * Load every case from the vectors directory, in file order.
52
+ * @param {{ dir?: string }} [options]
53
+ */
54
+ export async function loadCases({ dir = VECTORS_DIR } = {}) {
55
+ const files = await vectorFiles(dir);
56
+ const lists = await Promise.all(files.map((file) => readVectorFile(file, dir)));
57
+ return lists.flat();
58
+ }
59
+
60
+ /** The cases that have any of the tags. With no tags, every case. */
61
+ export function selectCases(cases, tags) {
62
+ if (!tags || tags.length === 0) return cases;
63
+ return cases.filter((item) => item.tags.some((tag) => tags.includes(tag)));
64
+ }
65
+
66
+ // --- Comparison -------------------------------------------------------------
67
+
68
+ function lowerKeys(headers = {}) {
69
+ const result = {};
70
+ for (const [name, value] of Object.entries(headers)) result[name.toLowerCase()] = value;
71
+ return result;
72
+ }
73
+
74
+ function asList(value) {
75
+ return Array.isArray(value) ? value : [value];
76
+ }
77
+
78
+ function asText(value) {
79
+ return Array.isArray(value) ? value.join(', ') : value;
80
+ }
81
+
82
+ function sameList(expected, actual) {
83
+ const list = asList(actual);
84
+ return list.length === expected.length && expected.every((item, i) => item === list[i]);
85
+ }
86
+
87
+ const MATCHERS = {
88
+ present: (_expected, actual) => actual !== undefined,
89
+ startsWith: (expected, actual) => typeof asText(actual) === 'string' && asText(actual).startsWith(expected),
90
+ includes: (expected, actual) => typeof asText(actual) === 'string' && asText(actual).includes(expected)
91
+ };
92
+
93
+ function headerMatches(matcher, actual) {
94
+ if (matcher === null) return actual === undefined;
95
+ if (Array.isArray(matcher)) return actual !== undefined && sameList(matcher, actual);
96
+ if (typeof matcher === 'string') return asText(actual) === matcher;
97
+ return Object.entries(matcher).every(([kind, value]) => MATCHERS[kind] && MATCHERS[kind](value, actual));
98
+ }
99
+
100
+ function headerFailures(expected = {}, actualHeaders = {}) {
101
+ const actual = lowerKeys(actualHeaders);
102
+ return Object.entries(expected)
103
+ .filter(([name, matcher]) => !headerMatches(matcher, actual[name.toLowerCase()]))
104
+ .map(([name, matcher]) => `header ${name}: expected ${JSON.stringify(matcher)}, got ${JSON.stringify(actual[name.toLowerCase()])}`);
105
+ }
106
+
107
+ function isSubset(expected, actual) {
108
+ if (Array.isArray(expected)) {
109
+ return Array.isArray(actual) && expected.length === actual.length && expected.every((item, i) => isSubset(item, actual[i]));
110
+ }
111
+ if (expected && typeof expected === 'object') {
112
+ return Boolean(actual) && typeof actual === 'object' && Object.keys(expected).every((key) => isSubset(expected[key], actual[key]));
113
+ }
114
+ return expected === actual;
115
+ }
116
+
117
+ function parseJson(text) {
118
+ try {
119
+ return { value: JSON.parse(text) };
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function jsonFailures(expected, body) {
126
+ if (expected === undefined) return [];
127
+ const parsed = parseJson(body);
128
+ if (!parsed) return [`body: expected JSON, got ${JSON.stringify(body)}`];
129
+ return isSubset(expected, parsed.value) ? [] : [`body JSON: expected to contain ${JSON.stringify(expected)}, got ${body}`];
130
+ }
131
+
132
+ function bodyFailures(expect, body) {
133
+ const failures = [];
134
+ if (expect.bodyText !== undefined && body !== expect.bodyText) {
135
+ failures.push(`body: expected ${JSON.stringify(expect.bodyText)}, got ${JSON.stringify(body)}`);
136
+ }
137
+ for (const part of expect.bodyIncludes || []) {
138
+ if (!body.includes(part)) failures.push(`body: expected to include ${JSON.stringify(part)}`);
139
+ }
140
+ for (const part of expect.bodyExcludes || []) {
141
+ if (body.includes(part)) failures.push(`body: expected not to include ${JSON.stringify(part)}`);
142
+ }
143
+ return [...failures, ...jsonFailures(expect.bodyJson, body)];
144
+ }
145
+
146
+ function ruleFailures(expect, actual) {
147
+ const ruleId = actual && actual.ruleId !== undefined ? actual.ruleId : null;
148
+ return ruleId === expect.ruleId ? [] : [`ruleId: expected ${JSON.stringify(expect.ruleId)}, got ${JSON.stringify(ruleId)}`];
149
+ }
150
+
151
+ function responseFailures(expect, actual) {
152
+ const status = actual.status === expect.status ? [] : [`status: expected ${expect.status}, got ${actual.status}`];
153
+ const body = actual.bodyText == null ? '' : String(actual.bodyText);
154
+ return [...status, ...headerFailures(expect.headers, actual.headers), ...bodyFailures(expect, body)];
155
+ }
156
+
157
+ /**
158
+ * Compare an adapter answer with a case's expected values.
159
+ * @returns {string[]} One message for each difference (empty when it passes).
160
+ */
161
+ export function checkResult(expect, actual) {
162
+ if ('ruleId' in expect) return ruleFailures(expect, actual);
163
+ return responseFailures(expect, actual || {});
164
+ }
165
+
166
+ // --- Run ----------------------------------------------------------------------
167
+
168
+ function skipReason(adapter, item) {
169
+ const capabilities = adapter.capabilities || {};
170
+ const ops = capabilities.ops || Object.keys(OPS).filter((op) => typeof adapter[op] === 'function');
171
+ if (!ops.includes(item.op) || typeof adapter[item.op] !== 'function') return `the adapter has no ${item.op}`;
172
+ const excluded = (capabilities.excludeTags || []).find((tag) => item.tags.includes(tag));
173
+ return excluded ? `the adapter excludes the tag ${excluded}` : null;
174
+ }
175
+
176
+ function errorText(err) {
177
+ return err && typeof err.message === 'string' ? err.message : String(err);
178
+ }
179
+
180
+ async function runCase(adapter, item) {
181
+ const summary = { id: item.id, op: item.op, tags: item.tags, file: item.file };
182
+ const reason = skipReason(adapter, item);
183
+ if (reason) return { ...summary, status: 'skipped', failures: [], reason };
184
+ try {
185
+ const input = structuredClone(item.input);
186
+ const actual = await adapter[item.op](...adapterArgs(item.op, input));
187
+ const failures = checkResult(item.expect, actual);
188
+ return { ...summary, status: failures.length ? 'failed' : 'passed', failures };
189
+ } catch (err) {
190
+ return { ...summary, status: 'failed', failures: [`the adapter threw: ${errorText(err)}`] };
191
+ }
192
+ }
193
+
194
+ function count(results, status) {
195
+ return results.filter((item) => item.status === status).length;
196
+ }
197
+
198
+ /**
199
+ * Run the cases against an adapter.
200
+ * @param {Object} options
201
+ * @param {import('./adapter').Adapter} options.adapter
202
+ * @param {string[]} [options.tags] Run only the cases with any of these tags.
203
+ * @param {Object[]} [options.cases] The cases (default: every vector).
204
+ * @returns {Promise<{ passed: number, failed: number, skipped: number, cases: Object[] }>}
205
+ */
206
+ export async function run({ adapter, tags, cases }) {
207
+ const selected = selectCases(cases || await loadCases(), tags);
208
+ const results = [];
209
+ try {
210
+ for (const item of selected) results.push(await runCase(adapter, item));
211
+ } finally {
212
+ if (typeof adapter.dispose === 'function') await adapter.dispose();
213
+ }
214
+ return { passed: count(results, 'passed'), failed: count(results, 'failed'), skipped: count(results, 'skipped'), cases: results };
215
+ }
@@ -0,0 +1,343 @@
1
+ {
2
+ "cases": [
3
+ {
4
+ "id": "functions-event-request-method",
5
+ "description": "event.request.method is the request method.",
6
+ "tags": [
7
+ "functions"
8
+ ],
9
+ "op": "runFunction",
10
+ "input": {
11
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
12
+ "type": "function",
13
+ "body": "return new Response(event.request.method);",
14
+ "request": {
15
+ "method": "POST",
16
+ "url": "https://www.example.com/api",
17
+ "body": "x"
18
+ }
19
+ },
20
+ "expect": {
21
+ "status": 200,
22
+ "bodyText": "POST"
23
+ }
24
+ },
25
+ {
26
+ "id": "functions-event-request-header-names-ignore-case",
27
+ "description": "event.request.headers.get() ignores the case of the header name.",
28
+ "tags": [
29
+ "functions"
30
+ ],
31
+ "op": "runFunction",
32
+ "input": {
33
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
34
+ "type": "function",
35
+ "body": "return new Response(event.request.headers.get('x-CUSTOM'));",
36
+ "request": {
37
+ "method": "GET",
38
+ "url": "https://www.example.com/api",
39
+ "headers": {
40
+ "X-Custom": "abc"
41
+ }
42
+ }
43
+ },
44
+ "expect": {
45
+ "status": 200,
46
+ "bodyText": "abc"
47
+ }
48
+ },
49
+ {
50
+ "id": "functions-event-request-text-body",
51
+ "description": "A POST body is readable with event.request.text().",
52
+ "tags": [
53
+ "functions"
54
+ ],
55
+ "op": "runFunction",
56
+ "input": {
57
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
58
+ "type": "function",
59
+ "body": "return new Response(await event.request.text());",
60
+ "request": {
61
+ "method": "POST",
62
+ "url": "https://www.example.com/api",
63
+ "headers": {
64
+ "content-type": "text/plain"
65
+ },
66
+ "body": "name=ada"
67
+ }
68
+ },
69
+ "expect": {
70
+ "status": 200,
71
+ "bodyText": "name=ada"
72
+ }
73
+ },
74
+ {
75
+ "id": "functions-event-request-json-body",
76
+ "description": "A JSON POST body is readable with event.request.json().",
77
+ "tags": [
78
+ "functions"
79
+ ],
80
+ "op": "runFunction",
81
+ "input": {
82
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
83
+ "type": "function",
84
+ "body": "return new Response((await event.request.json()).name);",
85
+ "request": {
86
+ "method": "POST",
87
+ "url": "https://www.example.com/api",
88
+ "headers": {
89
+ "content-type": "application/json"
90
+ },
91
+ "body": "{\"name\":\"ada\"}"
92
+ }
93
+ },
94
+ "expect": {
95
+ "status": 200,
96
+ "bodyText": "ada"
97
+ }
98
+ },
99
+ {
100
+ "id": "functions-get-request-has-no-body",
101
+ "description": "A GET request has a null body.",
102
+ "tags": [
103
+ "functions",
104
+ "unverified"
105
+ ],
106
+ "op": "runFunction",
107
+ "input": {
108
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
109
+ "type": "function",
110
+ "body": "return new Response(String(event.request.body === null));",
111
+ "request": {
112
+ "method": "GET",
113
+ "url": "https://www.example.com/api"
114
+ }
115
+ },
116
+ "expect": {
117
+ "status": 200,
118
+ "bodyText": "true"
119
+ }
120
+ },
121
+ {
122
+ "id": "functions-event-request-url",
123
+ "description": "event.request.url keeps the path and the query of the request.",
124
+ "tags": [
125
+ "functions"
126
+ ],
127
+ "op": "runFunction",
128
+ "input": {
129
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
130
+ "type": "function",
131
+ "body": "const u = new URL(event.request.url);\nreturn new Response(u.pathname + u.search);",
132
+ "request": {
133
+ "method": "GET",
134
+ "url": "https://www.example.com/api/items?page=2"
135
+ }
136
+ },
137
+ "expect": {
138
+ "status": 200,
139
+ "bodyText": "/api/items?page=2"
140
+ }
141
+ },
142
+ {
143
+ "id": "functions-event-url-keeps-the-query",
144
+ "description": "For a function rule, event.url keeps the query of the request.",
145
+ "tags": [
146
+ "functions"
147
+ ],
148
+ "op": "runFunction",
149
+ "input": {
150
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
151
+ "type": "function",
152
+ "body": "return new Response(new URL(event.url).search);",
153
+ "request": {
154
+ "method": "GET",
155
+ "url": "https://www.example.com/api/items?page=2"
156
+ }
157
+ },
158
+ "expect": {
159
+ "status": 200,
160
+ "bodyText": "?page=2"
161
+ }
162
+ },
163
+ {
164
+ "id": "functions-auth-event-url-is-the-request-url",
165
+ "description": "For an auth rule, event.url has the request path.",
166
+ "tags": [
167
+ "functions"
168
+ ],
169
+ "op": "runFunction",
170
+ "input": {
171
+ "uuid": "b2c3d4e5-0002-4000-8000-000000000002",
172
+ "type": "auth",
173
+ "body": "return new Response(new URL(event.url).pathname, { status: 401 });",
174
+ "request": {
175
+ "method": "GET",
176
+ "url": "https://www.example.com/members/area"
177
+ }
178
+ },
179
+ "expect": {
180
+ "status": 401,
181
+ "bodyText": "/members/area"
182
+ }
183
+ },
184
+ {
185
+ "id": "functions-client-ip-header",
186
+ "description": "The quant-client-ip request header holds the client address.",
187
+ "tags": [
188
+ "functions"
189
+ ],
190
+ "op": "runFunction",
191
+ "input": {
192
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
193
+ "type": "function",
194
+ "body": "const ip = event.request.headers.get('quant-client-ip');\nreturn new Response(String(typeof ip === 'string' && ip.length > 0));",
195
+ "request": {
196
+ "method": "GET",
197
+ "url": "https://www.example.com/api"
198
+ }
199
+ },
200
+ "expect": {
201
+ "status": 200,
202
+ "bodyText": "true"
203
+ }
204
+ },
205
+ {
206
+ "id": "functions-client-ip-header-cannot-be-set-by-the-client",
207
+ "description": "A quant-client-ip header that the client sends does not reach the function.",
208
+ "tags": [
209
+ "functions"
210
+ ],
211
+ "op": "runFunction",
212
+ "input": {
213
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
214
+ "type": "function",
215
+ "body": "return new Response(String(event.request.headers.get('quant-client-ip') !== '203.0.113.9'));",
216
+ "request": {
217
+ "method": "GET",
218
+ "url": "https://www.example.com/api",
219
+ "headers": {
220
+ "quant-client-ip": "203.0.113.9"
221
+ }
222
+ }
223
+ },
224
+ "expect": {
225
+ "status": 200,
226
+ "bodyText": "true"
227
+ }
228
+ },
229
+ {
230
+ "id": "functions-geo-fields",
231
+ "description": "event.geo has the documented geolocation fields.",
232
+ "tags": [
233
+ "functions"
234
+ ],
235
+ "op": "runFunction",
236
+ "input": {
237
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
238
+ "type": "function",
239
+ "body": "return new Response(JSON.stringify({ country_code: typeof event.geo.country_code, country_name: typeof event.geo.country_name, city: typeof event.geo.city, continent: typeof event.geo.continent, latitude: typeof event.geo.latitude, longitude: typeof event.geo.longitude, utc_offset: typeof event.geo.utc_offset }), { headers: { 'content-type': 'application/json' } });",
240
+ "request": {
241
+ "method": "GET",
242
+ "url": "https://www.example.com/api"
243
+ }
244
+ },
245
+ "expect": {
246
+ "status": 200,
247
+ "bodyJson": {
248
+ "country_code": "string",
249
+ "country_name": "string",
250
+ "city": "string",
251
+ "continent": "string",
252
+ "latitude": "number",
253
+ "longitude": "number",
254
+ "utc_offset": "number"
255
+ }
256
+ }
257
+ },
258
+ {
259
+ "id": "functions-node-globals-are-absent",
260
+ "description": "Node.js globals such as process and require do not exist in a function.",
261
+ "tags": [
262
+ "functions"
263
+ ],
264
+ "op": "runFunction",
265
+ "input": {
266
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
267
+ "type": "function",
268
+ "body": "return new Response(JSON.stringify({ process: typeof process, require: typeof require, module: typeof module, exports: typeof exports, __dirname: typeof __dirname, __filename: typeof __filename }), { headers: { 'content-type': 'application/json' } });",
269
+ "request": {
270
+ "method": "GET",
271
+ "url": "https://www.example.com/api"
272
+ }
273
+ },
274
+ "expect": {
275
+ "status": 200,
276
+ "bodyJson": {
277
+ "process": "undefined",
278
+ "require": "undefined",
279
+ "module": "undefined",
280
+ "exports": "undefined",
281
+ "__dirname": "undefined",
282
+ "__filename": "undefined"
283
+ }
284
+ }
285
+ },
286
+ {
287
+ "id": "functions-web-globals-are-present",
288
+ "description": "The web platform globals exist in a function.",
289
+ "tags": [
290
+ "functions"
291
+ ],
292
+ "op": "runFunction",
293
+ "input": {
294
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
295
+ "type": "function",
296
+ "body": "return new Response(JSON.stringify({ fetch: typeof fetch, Request: typeof Request, Response: typeof Response, Headers: typeof Headers, URL: typeof URL, URLSearchParams: typeof URLSearchParams, TextEncoder: typeof TextEncoder, TextDecoder: typeof TextDecoder, TransformStream: typeof TransformStream, atob: typeof atob, btoa: typeof btoa, crypto: typeof crypto, setTimeout: typeof setTimeout }), { headers: { 'content-type': 'application/json' } });",
297
+ "request": {
298
+ "method": "GET",
299
+ "url": "https://www.example.com/api"
300
+ }
301
+ },
302
+ "expect": {
303
+ "status": 200,
304
+ "bodyJson": {
305
+ "fetch": "function",
306
+ "Request": "function",
307
+ "Response": "function",
308
+ "Headers": "function",
309
+ "URL": "function",
310
+ "URLSearchParams": "function",
311
+ "TextEncoder": "function",
312
+ "TextDecoder": "function",
313
+ "TransformStream": "function",
314
+ "atob": "function",
315
+ "btoa": "function",
316
+ "crypto": "object",
317
+ "setTimeout": "function"
318
+ }
319
+ }
320
+ },
321
+ {
322
+ "id": "functions-btoa",
323
+ "description": "btoa encodes a string as base64, as the basic auth example uses it.",
324
+ "tags": [
325
+ "functions"
326
+ ],
327
+ "op": "runFunction",
328
+ "input": {
329
+ "uuid": "b2c3d4e5-0001-4000-8000-000000000001",
330
+ "type": "function",
331
+ "body": "return new Response(btoa('user:pass'));",
332
+ "request": {
333
+ "method": "GET",
334
+ "url": "https://www.example.com/api"
335
+ }
336
+ },
337
+ "expect": {
338
+ "status": 200,
339
+ "bodyText": "dXNlcjpwYXNz"
340
+ }
341
+ }
342
+ ]
343
+ }