zuplo 7.4.4 → 7.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/docs/articles/ci-cd-azure/local-testing.mdx +22 -4
  2. package/docs/articles/ci-cd-bitbucket/local-testing.mdx +22 -4
  3. package/docs/articles/ci-cd-circleci/local-testing.mdx +21 -4
  4. package/docs/articles/ci-cd-github/deploy-and-test.mdx +28 -13
  5. package/docs/articles/ci-cd-github/local-testing.mdx +36 -16
  6. package/docs/articles/ci-cd-gitlab/local-testing.mdx +22 -4
  7. package/docs/articles/github-deployment-testing.mdx +118 -31
  8. package/docs/articles/testing-getting-started.mdx +220 -0
  9. package/docs/articles/testing-preview-environments.mdx +148 -0
  10. package/docs/articles/testing-recipes.mdx +429 -0
  11. package/docs/articles/testing.mdx +138 -408
  12. package/docs/mcp-gateway/auth/configuring-auth0.mdx +6 -5
  13. package/docs/mcp-gateway/auth/configuring-clerk.mdx +4 -4
  14. package/docs/mcp-gateway/auth/configuring-cognito.mdx +5 -4
  15. package/docs/mcp-gateway/auth/configuring-entra.mdx +5 -4
  16. package/docs/mcp-gateway/auth/configuring-generic-oidc.mdx +8 -8
  17. package/docs/mcp-gateway/auth/configuring-google.mdx +4 -4
  18. package/docs/mcp-gateway/auth/configuring-keycloak.mdx +4 -3
  19. package/docs/mcp-gateway/auth/configuring-logto.mdx +4 -4
  20. package/docs/mcp-gateway/auth/configuring-okta.mdx +3 -3
  21. package/docs/mcp-gateway/auth/configuring-onelogin.mdx +3 -3
  22. package/docs/mcp-gateway/auth/configuring-ping.mdx +3 -3
  23. package/docs/mcp-gateway/auth/configuring-workos.mdx +5 -4
  24. package/docs/mcp-gateway/auth/manual-oauth-testing.mdx +8 -8
  25. package/docs/mcp-gateway/auth/overview.mdx +17 -17
  26. package/docs/mcp-gateway/auth/upstream-oauth.mdx +5 -5
  27. package/docs/mcp-gateway/code-config/local-development.mdx +14 -12
  28. package/docs/mcp-gateway/code-config/overview.mdx +9 -4
  29. package/docs/mcp-gateway/connect-clients/chatgpt.mdx +114 -56
  30. package/docs/mcp-gateway/how-it-works.mdx +11 -9
  31. package/docs/mcp-gateway/introduction.mdx +3 -1
  32. package/docs/mcp-gateway/quickstart-local.mdx +7 -7
  33. package/docs/mcp-gateway/reference.mdx +58 -25
  34. package/docs/mcp-gateway/server-registry.mdx +179 -0
  35. package/docs/mcp-gateway/test-clients.mdx +2 -2
  36. package/docs/mcp-server/custom-tools.mdx +32 -0
  37. package/docs/programmable-api/mcp-gateway-plugin.mdx +137 -0
  38. package/docs/programmable-api/mcp-sdk.mdx +240 -0
  39. package/docs/self-hosted/overview.md +2 -0
  40. package/package.json +5 -5
@@ -0,0 +1,429 @@
1
+ ---
2
+ title: Gateway test recipes
3
+ sidebar_label: Test recipes
4
+ description: >-
5
+ Copy-pasteable TypeScript test files for the assertions that matter at a
6
+ gateway—auth rejections, deterministic rate limits, response schema validation
7
+ with Zod, OpenAPI conformance, error contracts, routing, and test data
8
+ hygiene.
9
+ ---
10
+
11
+ The interesting assertions at a gateway are the rejections. A suite that only
12
+ sends well-formed requests with correct credentials passes while a policy is
13
+ attached to the wrong route — because the request still succeeds, for the wrong
14
+ caller.
15
+
16
+ Each recipe below is a complete file you can drop into `tests/`. They assume the
17
+ setup from [Get started with zuplo test](./testing-getting-started.mdx), which
18
+ installs chai for the assertions. The two schema recipes add one library each
19
+ and say so inline.
20
+
21
+ All of them use the same helper for building URLs:
22
+
23
+ ```ts
24
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
25
+ ```
26
+
27
+ ## Auth rejections
28
+
29
+ Test every way a request can be turned away, and finish with the case that
30
+ matters most: a valid token from one tenant asking for another tenant's object.
31
+ Broken object-level authorization is invisible to happy-path testing because the
32
+ request succeeds.
33
+
34
+ ```ts title="/tests/auth.test.ts"
35
+ import { describe, it, TestHelper } from "@zuplo/test";
36
+ import { expect } from "chai";
37
+
38
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
39
+
40
+ describe("auth policy", () => {
41
+ it("rejects requests with no token", async () => {
42
+ const response = await fetch(url("/v1/orders/ord_1001"));
43
+ expect(response.status).to.equal(401);
44
+ });
45
+
46
+ it("rejects expired tokens", async () => {
47
+ const response = await fetch(url("/v1/orders/ord_1001"), {
48
+ headers: {
49
+ Authorization: `Bearer ${TestHelper.environment.EXPIRED_JWT}`,
50
+ },
51
+ });
52
+ expect(response.status).to.equal(401);
53
+ });
54
+
55
+ it("rejects tokens issued for a different audience", async () => {
56
+ const response = await fetch(url("/v1/orders/ord_1001"), {
57
+ headers: {
58
+ Authorization: `Bearer ${TestHelper.environment.WRONG_AUDIENCE_JWT}`,
59
+ },
60
+ });
61
+ expect(response.status).to.equal(401);
62
+ });
63
+
64
+ it("does not serve another tenant's order", async () => {
65
+ // Tenant A's valid token, tenant B's order ID.
66
+ const response = await fetch(url("/v1/orders/ord_2002"), {
67
+ headers: {
68
+ Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
69
+ },
70
+ });
71
+ expect([403, 404]).to.include(response.status);
72
+ });
73
+ });
74
+ ```
75
+
76
+ Run the same file against every route that shares the policy. A route added
77
+ later without the policy attached is exactly the defect this catches.
78
+
79
+ ## Rate limits, deterministically
80
+
81
+ Most rate limit tests are flaky for two reasons: tests share a bucket, and tests
82
+ sleep through real time windows. Fix both.
83
+
84
+ Give each test its own bucket by generating a fresh value for whatever the
85
+ policy keys on — an API key, a client ID, a header. Then send requests one at a
86
+ time and stop at the first `429`.
87
+
88
+ ```ts title="/tests/rate-limit.test.ts"
89
+ import { describe, it, TestHelper } from "@zuplo/test";
90
+ import { expect } from "chai";
91
+
92
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
93
+
94
+ describe("rate limit policy", () => {
95
+ it("returns 429 with Retry-After when the limit is exceeded", async () => {
96
+ // A unique bucket per run: no other test, and no previous run of this
97
+ // test, can have consumed any of this budget.
98
+ const clientId = `rl-test-${crypto.randomUUID()}`;
99
+
100
+ let response!: Response;
101
+ for (let i = 0; i < 50; i++) {
102
+ response = await fetch(url("/v1/search?q=gateways"), {
103
+ headers: { "client-id": clientId },
104
+ });
105
+ if (response.status === 429) break;
106
+ }
107
+
108
+ expect(response.status).to.equal(429);
109
+ expect(response.headers.get("retry-after")).to.not.be.null;
110
+ });
111
+ });
112
+ ```
113
+
114
+ Four details are load-bearing:
115
+
116
+ - **Point the test at a low limit.** Test a route configured for a handful of
117
+ requests, not your production 10,000/minute tier. The behavior you are
118
+ verifying — that the policy is attached, keyed correctly, and returns the
119
+ right response — is identical either way. A test that has to send ten thousand
120
+ requests to prove it is slow, expensive, and unkind to shared infrastructure.
121
+ If your real limits are high, add a test-only route with a small limit rather
122
+ than exercising the big one.
123
+ - **A generous upper bound, not the exact limit.** Asserting "request N succeeds
124
+ and request N+1 fails" breaks the moment the configured limit changes or
125
+ anything else touches the window.
126
+ - **Sequential, not concurrent.** Firing a burst with `Promise.all` can
127
+ under-count against a distributed bucket, so the burst sometimes slips through
128
+ and the test fails for no reason you can reproduce.
129
+ - **Assert the whole contract.** The status and the `Retry-After` header, both
130
+ of which are settled standards from RFC 6585.
131
+
132
+ ### Distributed counters are eventually consistent
133
+
134
+ Rate limiting runs at the edge, across regions, and the counter behind it
135
+ converges rather than updating everywhere at once. Two consequences for tests:
136
+
137
+ - **Never assert an exact count.** "The 11th request is the one that 429s" is
138
+ not a property the system guarantees. A request that lands in a region whose
139
+ view of the counter is a moment stale can succeed past the nominal limit, and
140
+ a retry can be counted twice. Assert that a 429 arrives _within a bounded
141
+ number of attempts_, which is what the loop above does.
142
+ - **Give propagation a moment.** Immediately after the first request opens a
143
+ window, a request served from a different region may not see it yet. If a test
144
+ needs the counter to be shared, poll for the condition with a deadline instead
145
+ of sleeping a fixed amount and hoping.
146
+
147
+ The same reasoning applies to anything else backed by distributed state —
148
+ caches, quotas, and metering all trade exactness for latency. Test the
149
+ _behavior_ (a limit is enforced, a cached response is returned) rather than the
150
+ arithmetic.
151
+
152
+ :::caution
153
+
154
+ Do not assert on `RateLimit-Limit`, `RateLimit-Remaining`, or `RateLimit-Reset`.
155
+ Zuplo's [Rate Limit policy](../policies/rate-limit-inbound.mdx) emits
156
+ `Retry-After` and nothing else, so those assertions fail. They are also still an
157
+ IETF Internet-Draft rather than a standard, and their names and semantics have
158
+ changed between draft revisions.
159
+
160
+ :::
161
+
162
+ ## Response shape
163
+
164
+ Asserting a couple of fields leaves most of the response untested. A schema
165
+ checks the whole body in one line and tells you exactly which field is wrong
166
+ when it fails.
167
+
168
+ [Zod](https://zod.dev) is the recommended way to do this. The schema is ordinary
169
+ TypeScript, so there is no second language to learn and no build step:
170
+
171
+ ```bash
172
+ npm install --save-dev zod
173
+ ```
174
+
175
+ ```ts title="/tests/order-shape.test.ts"
176
+ import { describe, it, TestHelper } from "@zuplo/test";
177
+ import { expect } from "chai";
178
+ import { z } from "zod";
179
+
180
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
181
+
182
+ const Order = z.object({
183
+ id: z.string(),
184
+ status: z.enum(["pending", "shipped", "cancelled"]),
185
+ total: z.number(),
186
+ customerId: z.string(),
187
+ note: z.string().optional(),
188
+ });
189
+
190
+ describe("order shape", () => {
191
+ it("returns a well-formed order", async () => {
192
+ const response = await fetch(url("/v1/orders/ord_1001"), {
193
+ headers: {
194
+ Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
195
+ },
196
+ });
197
+ expect(response.status).to.equal(200);
198
+
199
+ const result = Order.safeParse(await response.json());
200
+ expect(result.success, JSON.stringify(result.error?.issues, null, 2)).to.be
201
+ .true;
202
+ });
203
+ });
204
+ ```
205
+
206
+ Two things to know. `safeParse` returns a result rather than throwing, so the
207
+ assertion message can carry every problem at once instead of only the first. And
208
+ `z.infer<typeof Order>` gives you a TypeScript type for free, so the rest of the
209
+ test is typed against the same definition it validated.
210
+
211
+ :::tip
212
+
213
+ Zod is strict about extra keys only if you ask. By default, unknown properties
214
+ are stripped and the parse still succeeds, which is usually what you want at a
215
+ gateway — an upstream adding a field should not fail your suite. Opt into a
216
+ strict object schema when you specifically want an unexpected field to fail.
217
+
218
+ :::
219
+
220
+ ## OpenAPI conformance
221
+
222
+ The recipe above tests against a shape you wrote by hand, which can drift from
223
+ the spec. When you want `config/routes.oas.json` itself to be the oracle — it
224
+ **is** the gateway configuration, so this closes the loop between the spec and
225
+ the deployed behavior — validate against the document instead.
226
+
227
+ That means JSON Schema, and Zod is the wrong tool for it. Zod schemas are
228
+ authored in TypeScript, not derived from a JSON Schema document. Use a JSON
229
+ Schema validator:
230
+
231
+ ```bash
232
+ npm install --save-dev ajv
233
+ ```
234
+
235
+ ```ts title="/tests/conformance.test.ts"
236
+ import { describe, it, TestHelper } from "@zuplo/test";
237
+ import { expect } from "chai";
238
+ import Ajv from "ajv";
239
+ import oas from "../config/routes.oas.json";
240
+
241
+ const ajv = new Ajv({ strict: false });
242
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
243
+
244
+ const orderSchema =
245
+ oas.paths["/v1/orders/{orderId}"].get.responses["200"].content[
246
+ "application/json"
247
+ ].schema;
248
+
249
+ describe("OpenAPI conformance", () => {
250
+ it("GET /v1/orders/{orderId} matches its declared schema", async () => {
251
+ const response = await fetch(url("/v1/orders/ord_1001"), {
252
+ headers: {
253
+ Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
254
+ },
255
+ });
256
+ expect(response.status).to.equal(200);
257
+
258
+ const body = await response.json();
259
+ const valid = ajv.validate(orderSchema, body);
260
+ expect(valid, ajv.errorsText(ajv.errors)).to.be.true;
261
+ });
262
+ });
263
+ ```
264
+
265
+ The path, method, and status key in the `orderSchema` lookup must exist in your
266
+ own `routes.oas.json` — TypeScript infers the type of an imported JSON module
267
+ from the file's actual contents, so a typo is a compile error rather than a
268
+ runtime surprise.
269
+
270
+ Pick one: Zod when you want a readable assertion about a response, Ajv when you
271
+ want the spec to be the thing under test.
272
+
273
+ ## The error contract
274
+
275
+ Errors are part of your API's contract, and they are the part a gateway is most
276
+ likely to change without anyone noticing. A policy swap can turn a structured
277
+ problem response into a bare 500, and no dashboard flags it.
278
+
279
+ Zuplo returns [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details
280
+ with a `content-type` of `application/problem+json`. Assert the shape, not the
281
+ status alone:
282
+
283
+ ```ts title="/tests/errors.test.ts"
284
+ import { describe, it, TestHelper } from "@zuplo/test";
285
+ import { expect } from "chai";
286
+ import { z } from "zod";
287
+
288
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
289
+
290
+ // RFC 9457 problem details.
291
+ const Problem = z.object({
292
+ type: z.string(),
293
+ title: z.string(),
294
+ status: z.number(),
295
+ detail: z.string(),
296
+ });
297
+
298
+ describe("error contract", () => {
299
+ it("returns problem+json for a malformed body", async () => {
300
+ const response = await fetch(url("/v1/orders"), {
301
+ method: "POST",
302
+ headers: {
303
+ Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
304
+ "content-type": "application/json",
305
+ },
306
+ body: JSON.stringify({ quantity: "several" }),
307
+ });
308
+
309
+ expect(response.status).to.equal(400);
310
+ expect(response.headers.get("content-type")).to.include(
311
+ "application/problem+json",
312
+ );
313
+
314
+ const result = Problem.safeParse(await response.json());
315
+ expect(result.success, JSON.stringify(result.error?.issues, null, 2)).to.be
316
+ .true;
317
+ });
318
+ });
319
+ ```
320
+
321
+ A hand-rolled `["type", "title"].every((key) => key in problem)` passes on
322
+ `{ type: 123 }` and, when it does fail, tells you only that `true` was expected.
323
+ The schema checks the types too and names the offending field.
324
+
325
+ ## Routing and CORS
326
+
327
+ Routing bugs are cheap to catch and expensive to miss. Assert that unknown paths
328
+ return 404, that a deprecated alias still resolves, and that the preflight
329
+ response carries the headers a browser needs.
330
+
331
+ ```ts title="/tests/routing.test.ts"
332
+ import { describe, it, TestHelper } from "@zuplo/test";
333
+ import { expect } from "chai";
334
+
335
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
336
+
337
+ describe("routing", () => {
338
+ it("404s an unknown path", async () => {
339
+ const response = await fetch(url("/v1/does-not-exist"));
340
+ expect(response.status).to.equal(404);
341
+ });
342
+
343
+ it("keeps the legacy alias working", async () => {
344
+ const response = await fetch(url("/orders/ord_1001"), {
345
+ headers: {
346
+ Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
347
+ },
348
+ });
349
+ expect(response.status).to.equal(200);
350
+ });
351
+
352
+ it("answers a CORS preflight", async () => {
353
+ const response = await fetch(url("/v1/orders"), {
354
+ method: "OPTIONS",
355
+ headers: {
356
+ Origin: "https://example.com",
357
+ "Access-Control-Request-Method": "GET",
358
+ },
359
+ });
360
+ expect(response.headers.get("access-control-allow-origin")).to.exist;
361
+ });
362
+ });
363
+ ```
364
+
365
+ An alias route is worth its own test for the same reason as the cross-tenant
366
+ case: it is the route most likely to be missing a policy that the canonical
367
+ route has.
368
+
369
+ ## Test data hygiene
370
+
371
+ Two tests that touch the same order, the same API key, or the same rate-limit
372
+ bucket eventually run at the same time and corrupt each other. Test files run in
373
+ parallel by default.
374
+
375
+ Each test creates what it needs through the API, owns it exclusively, and cleans
376
+ up after itself:
377
+
378
+ ```ts title="/tests/orders.test.ts"
379
+ import { afterEach, describe, it, TestHelper } from "@zuplo/test";
380
+ import { expect } from "chai";
381
+
382
+ const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();
383
+ const auth = {
384
+ Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`,
385
+ };
386
+
387
+ describe("orders", () => {
388
+ const created: string[] = [];
389
+
390
+ afterEach(async () => {
391
+ while (created.length > 0) {
392
+ await fetch(url(`/v1/orders/${created.pop()}`), {
393
+ method: "DELETE",
394
+ headers: auth,
395
+ });
396
+ }
397
+ });
398
+
399
+ it("returns an order it created", async () => {
400
+ const create = await fetch(url("/v1/orders"), {
401
+ method: "POST",
402
+ headers: { ...auth, "content-type": "application/json" },
403
+ body: JSON.stringify({ sku: "widget", quantity: 1 }),
404
+ });
405
+ expect(create.status).to.equal(201);
406
+
407
+ const { id } = await create.json();
408
+ created.push(id);
409
+
410
+ const read = await fetch(url(`/v1/orders/${id}`), { headers: auth });
411
+ expect(read.status).to.equal(200);
412
+ });
413
+ });
414
+ ```
415
+
416
+ Two rules follow from this:
417
+
418
+ - **No order dependency.** If test B passes only because test A ran first, you
419
+ have one long test with a hidden seam, and any filter or reorder exposes it.
420
+ - **A retry is not a fix.** If a test needs a rerun to pass, it has a shared
421
+ resource, a sleep, or the pipeline is reporting readiness before the gateway
422
+ is serving. See [testing GitHub deployments](./github-deployment-testing.mdx)
423
+ for the readiness poll.
424
+
425
+ ## Related
426
+
427
+ - [Get started with zuplo test](./testing-getting-started.mdx)
428
+ - [Testing preview environments](./testing-preview-environments.mdx)
429
+ - [Testing overview](./testing.mdx)