api-tracer-kit 1.0.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/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +466 -0
- package/cli/bin/api-tracer.mjs +266 -0
- package/cli/config.mjs +224 -0
- package/cli/import.mjs +231 -0
- package/cli/index.mjs +10 -0
- package/cli/presets.mjs +212 -0
- package/cli/report.mjs +346 -0
- package/cli/scan.mjs +142 -0
- package/cli/server.mjs +1576 -0
- package/cli/shape.mjs +90 -0
- package/cli/test.mjs +342 -0
- package/cli/web/app.css +1424 -0
- package/cli/web/app.js +2260 -0
- package/cli/web/favicon.svg +5 -0
- package/cli/web/index.html +159 -0
- package/cli/web/logo.svg +7 -0
- package/dist/axios.cjs +856 -0
- package/dist/axios.cjs.map +1 -0
- package/dist/axios.d.cts +27 -0
- package/dist/axios.d.ts +27 -0
- package/dist/axios.js +853 -0
- package/dist/axios.js.map +1 -0
- package/dist/index.cjs +872 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +74 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +857 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +896 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +22 -0
- package/dist/react.d.ts +22 -0
- package/dist/react.js +893 -0
- package/dist/react.js.map +1 -0
- package/dist/tracer-BUWdU2lG.d.ts +76 -0
- package/dist/tracer-DG2YUqK0.d.cts +76 -0
- package/dist/types-Bl2-K6_g.d.cts +111 -0
- package/dist/types-Bl2-K6_g.d.ts +111 -0
- package/dist/ui.cjs +1162 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.cts +16 -0
- package/dist/ui.d.ts +16 -0
- package/dist/ui.js +1157 -0
- package/dist/ui.js.map +1 -0
- package/package.json +92 -0
package/cli/shape.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response contracts: reduce a response body to its shape, then compare shapes
|
|
3
|
+
* across runs.
|
|
4
|
+
*
|
|
5
|
+
* The point is the failure a status check cannot see. This API answers 200 for
|
|
6
|
+
* almost everything, so a backend that quietly drops `data[].pinned` or turns an
|
|
7
|
+
* id from a number into a string still looks green. Comparing shapes catches it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** the shape of one value: primitives become their type name, containers recurse */
|
|
11
|
+
export function shapeOf(value) {
|
|
12
|
+
if (value === null) return 'null';
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
if (!value.length) return { '[]': 'empty' };
|
|
15
|
+
// union across elements, so a field missing from the first row is not lost
|
|
16
|
+
return { '[]': value.map(shapeOf).reduce(mergeShapes) };
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === 'object') {
|
|
19
|
+
return Object.fromEntries(
|
|
20
|
+
Object.entries(value)
|
|
21
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
22
|
+
.map(([k, v]) => [k, shapeOf(v)]),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return typeof value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** two shapes for the same slot; disagreement is recorded rather than resolved */
|
|
29
|
+
function mergeShapes(a, b) {
|
|
30
|
+
if (a === b) return a;
|
|
31
|
+
if (typeof a === 'string' || typeof b === 'string') {
|
|
32
|
+
if (a === 'null') return b;
|
|
33
|
+
if (b === 'null') return a;
|
|
34
|
+
return typeof a === 'string' && typeof b === 'string' ? [a, b].sort().join('|') : a;
|
|
35
|
+
}
|
|
36
|
+
const out = { ...a };
|
|
37
|
+
for (const [k, v] of Object.entries(b)) out[k] = k in out ? mergeShapes(out[k], v) : v;
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** nested shape -> { 'data[].id': 'number' }, which is what a human reads in a diff */
|
|
42
|
+
export function flatten(shape, prefix = '') {
|
|
43
|
+
if (typeof shape === 'string') return { [prefix || '.']: shape };
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
46
|
+
const path = key === '[]' ? `${prefix}[]` : prefix ? `${prefix}.${key}` : key;
|
|
47
|
+
if (typeof value === 'string') out[path] = value;
|
|
48
|
+
else Object.assign(out, flatten(value, path));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What changed between a baseline response and a new one. Ignores values
|
|
55
|
+
* entirely -- only the presence and type of each field matters.
|
|
56
|
+
*/
|
|
57
|
+
export function diffShapes(baseline, current) {
|
|
58
|
+
const a = flatten(baseline);
|
|
59
|
+
const b = flatten(current);
|
|
60
|
+
|
|
61
|
+
const removed = Object.keys(a).filter((k) => !(k in b));
|
|
62
|
+
const added = Object.keys(b).filter((k) => !(k in a));
|
|
63
|
+
const changed = Object.keys(a)
|
|
64
|
+
.filter((k) => k in b && a[k] !== b[k])
|
|
65
|
+
.map((path) => ({ path, from: a[path], to: b[path] }));
|
|
66
|
+
|
|
67
|
+
return { added, removed, changed, drifted: Boolean(added.length || removed.length || changed.length) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** a one-line summary for a badge: -data.pinned, +data.author */
|
|
71
|
+
export function summarizeDrift(diff, limit = 4) {
|
|
72
|
+
const parts = [
|
|
73
|
+
...diff.removed.map((p) => `-${p}`),
|
|
74
|
+
...diff.added.map((p) => `+${p}`),
|
|
75
|
+
...diff.changed.map((c) => `${c.path}: ${c.from}->${c.to}`),
|
|
76
|
+
];
|
|
77
|
+
return parts.length > limit
|
|
78
|
+
? `${parts.slice(0, limit).join(', ')} and ${parts.length - limit} more`
|
|
79
|
+
: parts.join(', ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** parses a body and returns its shape, or null when it is not JSON */
|
|
83
|
+
export function shapeOfBody(text) {
|
|
84
|
+
if (typeof text !== 'string' || !text.trim()) return null;
|
|
85
|
+
try {
|
|
86
|
+
return shapeOf(JSON.parse(text));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/cli/test.mjs
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One runnable check over the console: what gets sent, what counts as healthy,
|
|
4
|
+
* what the scanner reads, and what happens to traffic that is imported.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { buildUrl, fillVars, pluck, verdict, useCatalog } from './server.mjs';
|
|
12
|
+
import { scanProject } from './scan.mjs';
|
|
13
|
+
import { guessAuth, guessBaseUrls, guessPreset, collectFiles, defaults } from './config.mjs';
|
|
14
|
+
import { importHar, importCurl, matchEndpoint, parseQuery, toComparablePath } from './import.mjs';
|
|
15
|
+
import { shapeOf, diffShapes, summarizeDrift } from './shape.mjs';
|
|
16
|
+
import { buildReport, toMarkdown } from './report.mjs';
|
|
17
|
+
|
|
18
|
+
let checks = 0;
|
|
19
|
+
const check = (name, fn) => {
|
|
20
|
+
fn();
|
|
21
|
+
checks++;
|
|
22
|
+
process.stdout.write(` ok ${name}\n`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* The server builds URLs from the catalog it holds, so the tests give it a
|
|
27
|
+
* known one rather than depending on whatever was last scanned.
|
|
28
|
+
*/
|
|
29
|
+
useCatalog({ baseUrls: { dev: 'https://api.example.com/v1' }, auth: { header: 'AUTH_TOKEN' }, endpoints: [] });
|
|
30
|
+
|
|
31
|
+
const ep = (over = {}) => ({
|
|
32
|
+
subUrl: '/appointments/${data.id}.json',
|
|
33
|
+
holes: [{ expr: 'data.id', label: 'id' }],
|
|
34
|
+
absolute: false,
|
|
35
|
+
...over,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/* --- query params ------------------------------------------------------ */
|
|
39
|
+
|
|
40
|
+
const plain = ep({ subUrl: '/x', holes: [] });
|
|
41
|
+
check('arrays and nested objects serialize the axios way', () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
buildUrl(plain, {}, { page: 1, ids: [3, 4], filter: { status: 'new' } }),
|
|
44
|
+
'https://api.example.com/v1/x?page=1&ids%5B%5D=3&ids%5B%5D=4&filter%5Bstatus%5D=new',
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
check('empty and null params are dropped, but 0 is kept', () => {
|
|
48
|
+
assert.equal(buildUrl(plain, {}, { skip: '', gone: null, keep: 0 }), 'https://api.example.com/v1/x?keep=0');
|
|
49
|
+
});
|
|
50
|
+
check('raw mode passes the string through without re-encoding', () => {
|
|
51
|
+
assert.equal(buildUrl(plain, {}, {}, '?a=1&q=a b&enc=%2Bx'), 'https://api.example.com/v1/x?a=1&q=a b&enc=%2Bx');
|
|
52
|
+
});
|
|
53
|
+
check('stringified mode puts the whole object in one encoded param', () => {
|
|
54
|
+
assert.equal(buildUrl(plain, {}, { page: 1 }, '', 'params'), 'https://api.example.com/v1/x?params=%7B%22page%22%3A1%7D');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/* --- path holes -------------------------------------------------------- */
|
|
58
|
+
|
|
59
|
+
check('a path hole is filled from the supplied value', () => {
|
|
60
|
+
assert.equal(buildUrl(ep(), { 'data.id': '4821' }), 'https://api.example.com/v1/appointments/4821.json');
|
|
61
|
+
});
|
|
62
|
+
check('an unfilled hole leaves a blank rather than the literal template', () => {
|
|
63
|
+
assert.equal(buildUrl(ep(), {}), 'https://api.example.com/v1/appointments/.json');
|
|
64
|
+
});
|
|
65
|
+
check('an absolute endpoint ignores the base URL', () => {
|
|
66
|
+
const third = ep({ subUrl: 'https://api.postcodes.test/lookup', holes: [], absolute: true });
|
|
67
|
+
assert.equal(buildUrl(third, {}, { q: 'SW1' }), 'https://api.postcodes.test/lookup?q=SW1');
|
|
68
|
+
});
|
|
69
|
+
check('a missing base URL is reported rather than producing a broken URL', () => {
|
|
70
|
+
useCatalog({ baseUrls: {}, endpoints: [] });
|
|
71
|
+
assert.throws(() => buildUrl(ep({ subUrl: '/x', holes: [] })), /base URL/);
|
|
72
|
+
useCatalog({ baseUrls: { dev: 'https://api.example.com/v1' }, auth: { header: 'AUTH_TOKEN' }, endpoints: [] });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/* --- variables --------------------------------------------------------- */
|
|
76
|
+
|
|
77
|
+
check('an unknown variable is left visible rather than blanked', () => {
|
|
78
|
+
assert.equal(fillVars('/a/{{nope}}/b'), '/a/{{nope}}/b');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/* --- reading values out of a response ---------------------------------- */
|
|
82
|
+
|
|
83
|
+
check('a dotted path walks objects and arrays', () => {
|
|
84
|
+
assert.equal(pluck({ data: [{ id: 9 }] }, 'data.0.id'), 9);
|
|
85
|
+
assert.equal(pluck({ data: null }, 'data.0.id'), undefined);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/* --- healthy or not ---------------------------------------------------- */
|
|
89
|
+
|
|
90
|
+
const envelope = { codeFields: ['status', 'code'], okField: 'success', failFrom: 400 };
|
|
91
|
+
check('a 2xx carrying an error code is a failure', () => {
|
|
92
|
+
assert.equal(verdict(true, { status: 801, success: false }, envelope).ok, false);
|
|
93
|
+
assert.equal(verdict(true, { status: 801, success: false }, envelope).innerCode, 801);
|
|
94
|
+
});
|
|
95
|
+
check('a 2xx with a good envelope passes', () => {
|
|
96
|
+
assert.equal(verdict(true, { status: 200, success: true }, envelope).ok, true);
|
|
97
|
+
});
|
|
98
|
+
check('a non-2xx fails whatever the body says', () => {
|
|
99
|
+
assert.equal(verdict(false, { status: 200, success: true }, envelope).ok, false);
|
|
100
|
+
});
|
|
101
|
+
check('with no envelope configured the HTTP status decides alone', () => {
|
|
102
|
+
assert.equal(verdict(true, { status: 801, success: false }, false).ok, true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/* --- the scanner ------------------------------------------------------- */
|
|
106
|
+
|
|
107
|
+
const project = mkdtempSync(join(tmpdir(), 'api-tracer-'));
|
|
108
|
+
mkdirSync(join(project, 'src', 'services'), { recursive: true });
|
|
109
|
+
writeFileSync(
|
|
110
|
+
join(project, 'src', 'services', 'index.js'),
|
|
111
|
+
`export const authTokenKey = "X_AUTH";
|
|
112
|
+
const apiBaseUrls = { dev: "https://dev.example.com/api", prod: "https://example.com/api" };`,
|
|
113
|
+
);
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(project, 'src', 'services', 'patients.js'),
|
|
116
|
+
`import { get, post, deletee } from "./index";
|
|
117
|
+
|
|
118
|
+
// subUrl: \`/old/patients.json\`,
|
|
119
|
+
export const getPatients = (params) => {
|
|
120
|
+
const request = { subUrl: \`/v1/patients.json\`, params };
|
|
121
|
+
return get(request);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const getPatient = (data) => {
|
|
125
|
+
const request = { subUrl: \`/v1/patients/\${data.id}.json\` };
|
|
126
|
+
return get(request);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const createPatient = (data) => {
|
|
130
|
+
const request = { subUrl: \`/v1/patients.json\`, data };
|
|
131
|
+
return post(request);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const removePatient = (data) => {
|
|
135
|
+
const request = { subUrl: \`/v1/patients/\${data.id}.json\` };
|
|
136
|
+
return deletee(request);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const NOT_AN_ENDPOINT = 42;`,
|
|
140
|
+
);
|
|
141
|
+
writeFileSync(join(project, 'src', 'Page.jsx'), `import { getPatients } from "./services/patients";\ngetPatients();`);
|
|
142
|
+
|
|
143
|
+
const scanned = scanProject(project, { ...defaults, sources: ['src'] });
|
|
144
|
+
|
|
145
|
+
check('the preset is guessed rather than configured', () => {
|
|
146
|
+
assert.equal(scanned.preset, 'service-object');
|
|
147
|
+
});
|
|
148
|
+
check('every service function becomes an endpoint, and nothing else does', () => {
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
scanned.endpoints.map((e) => e.id).sort(),
|
|
151
|
+
['patients.createPatient', 'patients.getPatient', 'patients.getPatients', 'patients.removePatient'],
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
check('the verb, path and path holes are read correctly', () => {
|
|
155
|
+
const one = scanned.endpoints.find((e) => e.id === 'patients.getPatient');
|
|
156
|
+
assert.equal(one.method, 'GET');
|
|
157
|
+
assert.equal(one.subUrl, '/v1/patients/${data.id}.json');
|
|
158
|
+
assert.deepEqual(one.holes, [{ expr: 'data.id', label: 'id' }]);
|
|
159
|
+
});
|
|
160
|
+
check('a commented-out URL directly above the live one is not taken', () => {
|
|
161
|
+
assert.equal(scanned.endpoints.find((e) => e.id === 'patients.getPatients').subUrl, '/v1/patients.json');
|
|
162
|
+
});
|
|
163
|
+
check('params and body are told apart', () => {
|
|
164
|
+
assert.equal(scanned.endpoints.find((e) => e.id === 'patients.getPatients').usesParams, true);
|
|
165
|
+
assert.equal(scanned.endpoints.find((e) => e.id === 'patients.createPatient').usesData, true);
|
|
166
|
+
});
|
|
167
|
+
check('two functions on the same route are both kept', () => {
|
|
168
|
+
const sameRoute = scanned.endpoints.filter((e) => e.subUrl === '/v1/patients.json');
|
|
169
|
+
assert.equal(sameRoute.length, 2, 'a method+path fingerprint alone would collapse these');
|
|
170
|
+
});
|
|
171
|
+
check('an endpoint nothing references is flagged', () => {
|
|
172
|
+
assert.equal(scanned.endpoints.find((e) => e.id === 'patients.getPatients').usedIn, 1);
|
|
173
|
+
assert.equal(scanned.endpoints.find((e) => e.id === 'patients.removePatient').usedIn, 0);
|
|
174
|
+
});
|
|
175
|
+
check('base URLs and the auth header are read from the source', () => {
|
|
176
|
+
assert.deepEqual(scanned.baseUrls, { dev: 'https://dev.example.com/api', prod: 'https://example.com/api' });
|
|
177
|
+
assert.equal(scanned.auth.header, 'X_AUTH');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/* --- the other presets ------------------------------------------------- */
|
|
181
|
+
|
|
182
|
+
const other = mkdtempSync(join(tmpdir(), 'api-tracer-'));
|
|
183
|
+
mkdirSync(join(other, 'src'), { recursive: true });
|
|
184
|
+
writeFileSync(
|
|
185
|
+
join(other, 'src', 'api.js'),
|
|
186
|
+
`import axios from "axios";
|
|
187
|
+
export const listUsers = () => axios.get("/api/users", { params: { page: 1 } });
|
|
188
|
+
export const addUser = (body) => axios.post(\`/api/users/\${body.id}\`, body);`,
|
|
189
|
+
);
|
|
190
|
+
check('the axios-direct preset reads plain axios calls', () => {
|
|
191
|
+
const found = scanProject(other, { ...defaults, sources: ['src'], preset: 'axios-direct' });
|
|
192
|
+
assert.equal(found.endpoints.length, 2);
|
|
193
|
+
assert.deepEqual(found.endpoints.map((e) => e.method).sort(), ['GET', 'POST']);
|
|
194
|
+
assert.equal(found.endpoints.find((e) => e.method === 'POST').holes.length, 1);
|
|
195
|
+
});
|
|
196
|
+
check('guessing picks the preset that actually reads the code', () => {
|
|
197
|
+
assert.equal(guessPreset(collectFiles(other, defaults), defaults).preset, 'axios-direct');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const fetchy = mkdtempSync(join(tmpdir(), 'api-tracer-'));
|
|
201
|
+
mkdirSync(join(fetchy, 'src'), { recursive: true });
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(fetchy, 'src', 'client.js'),
|
|
204
|
+
`export const load = () => fetch("/api/things");
|
|
205
|
+
export const save = (b) => fetch("/api/things", { method: "POST", body: JSON.stringify(b) });`,
|
|
206
|
+
);
|
|
207
|
+
check('the fetch-direct preset reads plain fetch calls', () => {
|
|
208
|
+
const found = scanProject(fetchy, { ...defaults, sources: ['src'], preset: 'fetch-direct' });
|
|
209
|
+
assert.equal(found.endpoints.length, 2);
|
|
210
|
+
assert.deepEqual(found.endpoints.map((e) => e.method).sort(), ['GET', 'POST']);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
check('an empty project scans to nothing instead of throwing', () => {
|
|
214
|
+
const empty = mkdtempSync(join(tmpdir(), 'api-tracer-'));
|
|
215
|
+
const found = scanProject(empty, defaults);
|
|
216
|
+
assert.deepEqual(found.endpoints, []);
|
|
217
|
+
rmSync(empty, { recursive: true, force: true });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/* --- matching real traffic back to the catalog ------------------------- */
|
|
221
|
+
|
|
222
|
+
const ctx = { baseUrls: { dev: 'https://dev.example.com/api' }, endpoints: scanned.endpoints };
|
|
223
|
+
|
|
224
|
+
check('a concrete URL is matched back to its template', () => {
|
|
225
|
+
const hit = matchEndpoint('GET', '/v1/patients/4821.json', scanned.endpoints);
|
|
226
|
+
assert.equal(hit.endpoint.id, 'patients.getPatient');
|
|
227
|
+
assert.deepEqual(hit.pathValues, { 'data.id': '4821' });
|
|
228
|
+
});
|
|
229
|
+
check('a literal path is not swallowed by a greedier template', () => {
|
|
230
|
+
const hit = matchEndpoint('GET', '/v1/patients.json', scanned.endpoints);
|
|
231
|
+
assert.equal(hit.endpoint.subUrl, '/v1/patients.json');
|
|
232
|
+
});
|
|
233
|
+
check('a URL on another host is not ours', () => {
|
|
234
|
+
assert.equal(toComparablePath('https://elsewhere.test/v1/patients.json', ctx.baseUrls), null);
|
|
235
|
+
});
|
|
236
|
+
check('the axios query shapes are parsed back', () => {
|
|
237
|
+
assert.deepEqual(parseQuery('a=1&b[]=2&b[]=3&f[x]=9'), { a: '1', b: ['2', '3'], f: { x: '9' } });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const har = JSON.stringify({
|
|
241
|
+
log: {
|
|
242
|
+
entries: [
|
|
243
|
+
{
|
|
244
|
+
request: {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
url: 'https://dev.example.com/api/v1/patients.json?ref=x',
|
|
247
|
+
postData: { mimeType: 'application/json', text: '{"name":"a","password":"hunter2"}' },
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{ request: { method: 'GET', url: 'https://dev.example.com/api/v1/nothing.json' } },
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
check('a HAR import matches what it can and reports what it cannot', () => {
|
|
256
|
+
const out = importHar(har, ctx);
|
|
257
|
+
assert.equal(out.matched, 1);
|
|
258
|
+
assert.deepEqual(out.unmatched, ['no endpoint matches GET /v1/nothing.json']);
|
|
259
|
+
assert.equal(out.unmatchedCalls.length, 1, 'an unexplained call is still kept');
|
|
260
|
+
});
|
|
261
|
+
check('a secret in imported traffic never reaches the sample', () => {
|
|
262
|
+
const sample = Object.values(importHar(har, ctx).samples)[0];
|
|
263
|
+
assert.equal(sample.data.password, '<redacted>');
|
|
264
|
+
assert.equal(sample.data.name, 'a', 'the surrounding shape survives');
|
|
265
|
+
assert.deepEqual(sample.params, { ref: 'x' });
|
|
266
|
+
});
|
|
267
|
+
check('a copied cURL command is imported the same way', () => {
|
|
268
|
+
const out = importCurl(`curl 'https://dev.example.com/api/v1/patients/99.json' -H 'AUTH_TOKEN: secret'`, ctx);
|
|
269
|
+
assert.equal(out.matched, 1);
|
|
270
|
+
assert.equal(Object.values(out.samples)[0].pathValues['data.id'], '99');
|
|
271
|
+
});
|
|
272
|
+
check('a cURL with a body is treated as a POST, as curl itself would', () => {
|
|
273
|
+
const out = importCurl(
|
|
274
|
+
`curl 'https://dev.example.com/api/v1/patients.json' --data-raw '{"name":"a","token":"t"}'`,
|
|
275
|
+
ctx,
|
|
276
|
+
);
|
|
277
|
+
assert.equal(out.matched, 1);
|
|
278
|
+
const sample = Object.values(out.samples)[0];
|
|
279
|
+
assert.equal(sample.data.name, 'a');
|
|
280
|
+
assert.equal(sample.data.token, '<redacted>');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/* --- response contracts ------------------------------------------------ */
|
|
284
|
+
|
|
285
|
+
check('a shape ignores values and keeps types', () => {
|
|
286
|
+
assert.deepEqual(shapeOf({ id: 1, name: 'a' }), { id: 'number', name: 'string' });
|
|
287
|
+
});
|
|
288
|
+
check('an array shape is the union of its rows, so a sparse first row loses nothing', () => {
|
|
289
|
+
assert.deepEqual(shapeOf([{ a: 1 }, { a: 1, b: 't' }]), { '[]': { a: 'number', b: 'string' } });
|
|
290
|
+
});
|
|
291
|
+
check('drift reports what was added, removed and retyped', () => {
|
|
292
|
+
const diff = diffShapes(shapeOf({ id: 1, pinned: true }), shapeOf({ id: 'x', author: 'a' }));
|
|
293
|
+
assert.equal(diff.drifted, true);
|
|
294
|
+
assert.deepEqual(diff.removed, ['pinned']);
|
|
295
|
+
assert.deepEqual(diff.added, ['author']);
|
|
296
|
+
assert.deepEqual(diff.changed, [{ path: 'id', from: 'number', to: 'string' }]);
|
|
297
|
+
assert.equal(summarizeDrift(diff), '-pinned, +author, id: number->string');
|
|
298
|
+
});
|
|
299
|
+
check('an identical shape does not drift', () => {
|
|
300
|
+
assert.equal(diffShapes(shapeOf({ a: [1] }), shapeOf({ a: [2] })).drifted, false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/* --- the report -------------------------------------------------------- */
|
|
304
|
+
|
|
305
|
+
const report = buildReport({
|
|
306
|
+
catalog: { baseUrls: { dev: 'https://dev.example.com/api' }, auth: { header: 'X_AUTH' }, scannedAt: new Date().toISOString() },
|
|
307
|
+
endpoints: scanned.endpoints,
|
|
308
|
+
samples: { 'patients.getPatients': { params: { page: 1 }, data: {}, seenAt: new Date().toISOString() } },
|
|
309
|
+
results: { 'patients.getPatients': { ok: false, status: 200, innerCode: 801, ms: 12, failure: { body: '{"message":"no token"}' } } },
|
|
310
|
+
contracts: {},
|
|
311
|
+
runs: [],
|
|
312
|
+
env: 'dev',
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
check('the report measures coverage against the catalog', () => {
|
|
316
|
+
assert.equal(report.coverage.total, 4);
|
|
317
|
+
assert.equal(report.coverage.seen, 1);
|
|
318
|
+
assert.equal(report.coverage.pct, 25);
|
|
319
|
+
});
|
|
320
|
+
check('a 2xx carrying an error code is called out as a risk', () => {
|
|
321
|
+
assert.deepEqual(report.risks.envelopeErrors, [{ id: 'patients.getPatients', status: 200, innerCode: 801 }]);
|
|
322
|
+
});
|
|
323
|
+
check('the failure message is pulled out of the envelope', () => {
|
|
324
|
+
assert.equal(report.health.failing[0].message, 'no token');
|
|
325
|
+
});
|
|
326
|
+
check('writes nobody has exercised are listed', () => {
|
|
327
|
+
assert.ok(report.risks.untestedWrites.includes('patients.createPatient'));
|
|
328
|
+
});
|
|
329
|
+
check('duplicate routes are reported', () => {
|
|
330
|
+
assert.equal(report.hygiene.duplicates.length, 0, 'different methods on one path are not duplicates');
|
|
331
|
+
});
|
|
332
|
+
check('the markdown export renders without a run history', () => {
|
|
333
|
+
const md = toMarkdown(report);
|
|
334
|
+
assert.match(md, /# API report/);
|
|
335
|
+
assert.match(md, /Standard `X_AUTH`/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
rmSync(project, { recursive: true, force: true });
|
|
339
|
+
rmSync(other, { recursive: true, force: true });
|
|
340
|
+
rmSync(fetchy, { recursive: true, force: true });
|
|
341
|
+
|
|
342
|
+
console.log(`\n${checks} checks passed`);
|