@desplega.ai/agent-swarm 1.129.0 → 1.131.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 +2 -2
- package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
- package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
- package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
- package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
- package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
- package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
- package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
- package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
- package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
- package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
- package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
- package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
- package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
- package/dist/cli-3fj60etf.js +153 -0
- package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
- package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
- package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
- package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
- package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
- package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
- package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
- package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
- package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
- package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
- package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
- package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
- package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
- package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
- package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
- package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
- package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
- package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
- package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
- package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
- package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
- package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
- package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
- package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
- package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
- package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
- package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
- package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
- package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
- package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
- package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
- package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
- package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
- package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
- package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
- package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
- package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
- package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
- package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
- package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
- package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
- package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
- package/dist/cli-zsspvvm2.js +490 -0
- package/dist/cli.js +27 -25
- package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
- package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
- package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
- package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
- package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
- package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
- package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
- package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
- package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
- package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
- package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
- package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
- package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
- package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
- package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
- package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
- package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
- package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
- package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
- package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
- package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
- package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
- package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
- package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
- package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
- package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
- package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
- package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
- package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
- package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
- package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
- package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
- package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
- package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
- package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
- package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
- package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
- package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
- package/openapi.json +34039 -11036
- package/package.json +8 -6
- package/src/apps/definition.ts +368 -26
- package/src/apps/row-store.ts +66 -10
- package/src/apps/schema-migrate.ts +95 -1
- package/src/apps/store.ts +13 -4
- package/src/apps/sync-run-as.ts +18 -0
- package/src/apps/sync.ts +934 -0
- package/src/apps/version.ts +16 -2
- package/src/assets/key.ts +8 -1
- package/src/be/asset-key-audit.ts +3 -1
- package/src/be/db-queries/mcp-oauth.ts +297 -24
- package/src/be/db.ts +42 -1
- package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
- package/src/be/scripts/db.ts +5 -2
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
- package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
- package/src/be/seed-scripts/index.ts +18 -0
- package/src/be/seed-skills/index.ts +7 -0
- package/src/http/active-sessions.ts +26 -18
- package/src/http/agents.ts +94 -27
- package/src/http/api-keys.ts +91 -17
- package/src/http/approval-requests.ts +87 -10
- package/src/http/apps.ts +361 -53
- package/src/http/assets.ts +165 -31
- package/src/http/budgets.ts +7 -5
- package/src/http/codex-oauth-keep-warm.ts +21 -3
- package/src/http/config.ts +46 -16
- package/src/http/context.ts +33 -6
- package/src/http/ecosystem.ts +20 -6
- package/src/http/events.ts +24 -9
- package/src/http/favorites.ts +20 -6
- package/src/http/fs.ts +60 -17
- package/src/http/heartbeat.ts +18 -4
- package/src/http/inbox-state.ts +12 -6
- package/src/http/integrations.ts +25 -5
- package/src/http/kv.ts +36 -15
- package/src/http/mcp-bridge.ts +5 -1
- package/src/http/mcp-oauth.ts +332 -17
- package/src/http/mcp-servers.ts +56 -18
- package/src/http/memory.ts +282 -29
- package/src/http/metrics.ts +80 -17
- package/src/http/oauth-callback.ts +10 -4
- package/src/http/oauth-generic.ts +5 -1
- package/src/http/oauth-locks.ts +6 -3
- package/src/http/openapi.ts +57 -9
- package/src/http/page-proxy.ts +25 -5
- package/src/http/pages-public.ts +37 -20
- package/src/http/pages.ts +93 -28
- package/src/http/poll.ts +89 -5
- package/src/http/pricing.ts +9 -6
- package/src/http/prompt-templates.ts +85 -21
- package/src/http/route-def.ts +67 -4
- package/src/http/schedules.ts +63 -14
- package/src/http/script-connection-proxy.ts +30 -4
- package/src/http/script-connections.ts +393 -43
- package/src/http/script-runs.ts +73 -19
- package/src/http/scripts.ts +257 -39
- package/src/http/session-data.ts +87 -14
- package/src/http/sessions.ts +80 -5
- package/src/http/skills.ts +115 -35
- package/src/http/stats.ts +94 -13
- package/src/http/task-templates.ts +6 -4
- package/src/http/tasks.ts +230 -61
- package/src/http/trackers/jira.ts +90 -30
- package/src/http/trackers/linear.ts +46 -12
- package/src/http/users.ts +162 -25
- package/src/http/utils.ts +12 -2
- package/src/http/webhooks.ts +30 -16
- package/src/http/workflow-events.ts +18 -5
- package/src/http/workflows.ts +128 -39
- package/src/http/x.ts +37 -6
- package/src/oauth/ensure-mcp-token.ts +16 -1
- package/src/oauth/mcp-wrapper.ts +281 -26
- package/src/providers/pi-mono-adapter.ts +1 -1
- package/src/scripts-runtime/credential-broker/README.md +1 -1
- package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/stdlib/fetch.ts +6 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +2 -0
- package/src/tests/apps-rbac.test.ts +110 -0
- package/src/tests/apps-spike2.test.ts +4 -1
- package/src/tests/apps-sync-engine.test.ts +2540 -0
- package/src/tests/apps-sync.test.ts +1627 -0
- package/src/tests/asset-key-api.test.ts +48 -2
- package/src/tests/asset-key-invariants.test.ts +92 -0
- package/src/tests/asset-key-migration.test.ts +174 -8
- package/src/tests/asset-key-normalization.test.ts +2 -0
- package/src/tests/credential-broker.test.ts +27 -2
- package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
- package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
- package/src/tests/mcp-oauth-queries.test.ts +153 -0
- package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
- package/src/tests/memory-http-recall-gating.test.ts +2 -0
- package/src/tests/openapi-response-contract.test.ts +138 -0
- package/src/tests/rbac-admission-e2e.test.ts +68 -0
- package/src/tests/script-connections.test.ts +3 -0
- package/src/tests/scripts-runtime-fetch.test.ts +121 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/swarm-tool-result-gate.test.ts +32 -0
- package/src/tests/task-working-dir.test.ts +8 -4
- package/src/tests/whoami-route.test.ts +127 -0
- package/src/tests/workflow-engine-v2.test.ts +80 -7
- package/src/tests/workflow-swarm-script.test.ts +104 -0
- package/src/tools/app-get.ts +10 -2
- package/src/tools/app-rollback.ts +1 -0
- package/src/tools/app-sync.ts +112 -0
- package/src/tools/script-common.ts +20 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/utils.ts +11 -0
- package/src/tools/workflows/create-workflow.ts +4 -1
- package/src/types.ts +1394 -1206
- package/src/utils/template.ts +8 -1
- package/src/utils/zod-openapi.ts +11 -0
- package/src/workflows/engine.ts +27 -4
- package/src/workflows/executors/swarm-script.ts +28 -0
- package/src/workflows/retry-poller.ts +19 -2
- package/templates/skills/apps/content.md +94 -5
- package/templates/skills/asset-namespaces/content.md +2 -2
- package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
- package/templates/skills/scheduled-task-resilience/config.json +14 -0
- package/templates/skills/scheduled-task-resilience/content.md +97 -0
- package/dist/cli-7wmybrt5.js +0 -115
- package/dist/cli-d91pvmq1.js +0 -14
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
assertUrlSafe,
|
|
4
|
+
authMethodForStoredClient,
|
|
4
5
|
buildAuthorizeUrl,
|
|
5
6
|
computeExpiresAt,
|
|
6
7
|
discoverAuthorizationServerMetadata,
|
|
7
8
|
discoverProtectedResourceMetadata,
|
|
8
9
|
exchangeCodeForTokens,
|
|
10
|
+
normalizeTokenEndpointAuthMethod,
|
|
9
11
|
refreshMcpToken,
|
|
10
12
|
registerClient,
|
|
11
13
|
revokeMcpToken,
|
|
14
|
+
selectDcrTokenEndpointAuthMethod,
|
|
12
15
|
} from "../oauth/mcp-wrapper";
|
|
13
16
|
|
|
14
17
|
// ─── SSRF guard ──────────────────────────────────────────────────────────────
|
|
@@ -417,6 +420,26 @@ describe("registerClient (RFC 7591 DCR)", () => {
|
|
|
417
420
|
}),
|
|
418
421
|
).rejects.toThrow(/Dynamic client registration failed/);
|
|
419
422
|
});
|
|
423
|
+
|
|
424
|
+
test("attaches a bounded AbortSignal so a non-responding registration endpoint can't hang the caller forever", async () => {
|
|
425
|
+
let capturedSignal: AbortSignal | null | undefined;
|
|
426
|
+
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
|
|
427
|
+
capturedSignal = init?.signal;
|
|
428
|
+
return new Response(JSON.stringify({ client_id: "x" }), { status: 201 });
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
await registerClient("https://as.example.com/register", {
|
|
432
|
+
client_name: "x",
|
|
433
|
+
redirect_uris: ["https://swarm.example.com/cb"],
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Every safeFetch call (metadata discovery, DCR, token exchange,
|
|
437
|
+
// refresh, revocation) gets a default timeout — without it, a provider
|
|
438
|
+
// that accepts the connection and never responds would hold the
|
|
439
|
+
// per-connector authorize-flow lock indefinitely.
|
|
440
|
+
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
|
441
|
+
expect(capturedSignal?.aborted).toBe(false);
|
|
442
|
+
});
|
|
420
443
|
});
|
|
421
444
|
|
|
422
445
|
// ─── Token exchange + refresh + revoke ───────────────────────────────────────
|
|
@@ -449,6 +472,7 @@ describe("exchangeCodeForTokens", () => {
|
|
|
449
472
|
tokenUrl: "https://as.example.com/token",
|
|
450
473
|
clientId: "client-xyz",
|
|
451
474
|
clientSecret: "secret-xyz",
|
|
475
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
452
476
|
redirectUri: "https://swarm.example.com/callback",
|
|
453
477
|
code: "authcode-1",
|
|
454
478
|
codeVerifier: "verifier-1",
|
|
@@ -525,6 +549,38 @@ describe("refreshMcpToken", () => {
|
|
|
525
549
|
}),
|
|
526
550
|
).rejects.toThrow(/Token refresh failed \(400\)/);
|
|
527
551
|
});
|
|
552
|
+
|
|
553
|
+
test("scrubs an echoed client_secret/refresh_token from the thrown error body", async () => {
|
|
554
|
+
const clientSecret = "super-secret-client-value-123456";
|
|
555
|
+
const refreshToken = "super-secret-refresh-value-654321";
|
|
556
|
+
globalThis.fetch = async () =>
|
|
557
|
+
new Response(
|
|
558
|
+
JSON.stringify({
|
|
559
|
+
error: "invalid_client",
|
|
560
|
+
error_description: `client_secret ${clientSecret} rejected for refresh_token ${refreshToken}`,
|
|
561
|
+
}),
|
|
562
|
+
{ status: 401 },
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
let thrown: Error | undefined;
|
|
566
|
+
try {
|
|
567
|
+
await refreshMcpToken({
|
|
568
|
+
tokenUrl: "https://as.example.com/token",
|
|
569
|
+
clientId: "c",
|
|
570
|
+
clientSecret,
|
|
571
|
+
refreshToken,
|
|
572
|
+
resource: "https://mcp.example.com/",
|
|
573
|
+
});
|
|
574
|
+
} catch (err) {
|
|
575
|
+
thrown = err as Error;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
expect(thrown).toBeDefined();
|
|
579
|
+
expect(thrown!.message).not.toContain(clientSecret);
|
|
580
|
+
expect(thrown!.message).not.toContain(refreshToken);
|
|
581
|
+
expect(thrown!.message).toMatch(/\[REDACTED:mcp_oauth_client_secret]/);
|
|
582
|
+
expect(thrown!.message).toMatch(/\[REDACTED:mcp_oauth_refresh_token]/);
|
|
583
|
+
});
|
|
528
584
|
});
|
|
529
585
|
|
|
530
586
|
describe("revokeMcpToken (RFC 7009)", () => {
|
|
@@ -545,6 +601,7 @@ describe("revokeMcpToken (RFC 7009)", () => {
|
|
|
545
601
|
token: "t-1",
|
|
546
602
|
clientId: "c",
|
|
547
603
|
tokenTypeHint: "refresh_token",
|
|
604
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
548
605
|
});
|
|
549
606
|
|
|
550
607
|
const params = new URLSearchParams(capturedBody ?? "");
|
|
@@ -565,6 +622,381 @@ describe("revokeMcpToken (RFC 7009)", () => {
|
|
|
565
622
|
});
|
|
566
623
|
});
|
|
567
624
|
|
|
625
|
+
// ─── Token endpoint client authentication (RFC 6749 §2.3.1 / RFC 7591 §2) ────
|
|
626
|
+
|
|
627
|
+
describe("selectDcrTokenEndpointAuthMethod", () => {
|
|
628
|
+
test("prefers client_secret_basic when the AS advertises it", () => {
|
|
629
|
+
expect(
|
|
630
|
+
selectDcrTokenEndpointAuthMethod(["client_secret_post", "client_secret_basic", "none"]),
|
|
631
|
+
).toBe("client_secret_basic");
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("falls back to client_secret_post when basic isn't advertised", () => {
|
|
635
|
+
expect(selectDcrTokenEndpointAuthMethod(["client_secret_post", "none"])).toBe(
|
|
636
|
+
"client_secret_post",
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("falls back to none when only public-client auth is advertised", () => {
|
|
641
|
+
expect(selectDcrTokenEndpointAuthMethod(["none"])).toBe("none");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("an explicitly empty advertised list takes the RFC 8414 default, it is not an incompatibility", () => {
|
|
645
|
+
// Degenerate serialization, not a claim to support nothing. Erroring here
|
|
646
|
+
// would break such a provider outright for no safety gain.
|
|
647
|
+
expect(selectDcrTokenEndpointAuthMethod([])).toBe("client_secret_basic");
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("throws when the AS advertises only methods we cannot perform", () => {
|
|
651
|
+
// Silently registering a Basic client against an AS that offers only
|
|
652
|
+
// private_key_jwt produces a client we can never authenticate, surfacing
|
|
653
|
+
// later as an opaque invalid_client. Fail early and actionably instead.
|
|
654
|
+
expect(() => selectDcrTokenEndpointAuthMethod(["private_key_jwt"])).toThrow(/cannot perform/);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("defaults to client_secret_basic when metadata is absent (RFC 7591 §2)", () => {
|
|
658
|
+
expect(selectDcrTokenEndpointAuthMethod(undefined)).toBe("client_secret_basic");
|
|
659
|
+
expect(selectDcrTokenEndpointAuthMethod([])).toBe("client_secret_basic");
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
describe("normalizeTokenEndpointAuthMethod", () => {
|
|
664
|
+
test("passes through known values", () => {
|
|
665
|
+
expect(normalizeTokenEndpointAuthMethod("client_secret_basic")).toBe("client_secret_basic");
|
|
666
|
+
expect(normalizeTokenEndpointAuthMethod("client_secret_post")).toBe("client_secret_post");
|
|
667
|
+
expect(normalizeTokenEndpointAuthMethod("none")).toBe("none");
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("defaults missing/unknown values to client_secret_basic, not body-post", () => {
|
|
671
|
+
expect(normalizeTokenEndpointAuthMethod(undefined)).toBe("client_secret_basic");
|
|
672
|
+
expect(normalizeTokenEndpointAuthMethod(null)).toBe("client_secret_basic");
|
|
673
|
+
expect(normalizeTokenEndpointAuthMethod("")).toBe("client_secret_basic");
|
|
674
|
+
expect(normalizeTokenEndpointAuthMethod("private_key_jwt")).toBe("client_secret_basic");
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
describe("authMethodForStoredClient", () => {
|
|
679
|
+
test("preserves legacy body-post behavior when the field is absent", () => {
|
|
680
|
+
expect(authMethodForStoredClient(undefined)).toBe("client_secret_post");
|
|
681
|
+
expect(authMethodForStoredClient(null)).toBe("client_secret_post");
|
|
682
|
+
expect(authMethodForStoredClient("")).toBe("client_secret_post");
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe("token-endpoint client authentication is applied per the registered method", () => {
|
|
687
|
+
const original = globalThis.fetch;
|
|
688
|
+
afterEach(() => {
|
|
689
|
+
globalThis.fetch = original;
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
async function captureExchange(
|
|
693
|
+
tokenEndpointAuthMethod: string | null | undefined,
|
|
694
|
+
creds: { clientId?: string; clientSecret?: string } = {},
|
|
695
|
+
) {
|
|
696
|
+
let capturedBody = "";
|
|
697
|
+
let capturedHeaders: Record<string, string> = {};
|
|
698
|
+
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
|
|
699
|
+
capturedBody = (init?.body as string) ?? "";
|
|
700
|
+
capturedHeaders = (init?.headers as Record<string, string>) ?? {};
|
|
701
|
+
return new Response(JSON.stringify({ access_token: "at-1" }), {
|
|
702
|
+
status: 200,
|
|
703
|
+
headers: { "Content-Type": "application/json" },
|
|
704
|
+
});
|
|
705
|
+
};
|
|
706
|
+
await exchangeCodeForTokens({
|
|
707
|
+
tokenUrl: "https://as.example.com/token",
|
|
708
|
+
clientId: creds.clientId ?? "client-xyz",
|
|
709
|
+
clientSecret: creds.clientSecret ?? "secret-xyz",
|
|
710
|
+
tokenEndpointAuthMethod,
|
|
711
|
+
redirectUri: "https://swarm.example.com/callback",
|
|
712
|
+
code: "authcode-1",
|
|
713
|
+
codeVerifier: "verifier-1",
|
|
714
|
+
resource: "https://mcp.example.com/",
|
|
715
|
+
});
|
|
716
|
+
return { params: new URLSearchParams(capturedBody), headers: capturedHeaders };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
test("client_secret_basic → Authorization: Basic header, no creds in the body", async () => {
|
|
720
|
+
const { params, headers } = await captureExchange("client_secret_basic");
|
|
721
|
+
expect(headers.Authorization).toBe(
|
|
722
|
+
`Basic ${Buffer.from("client-xyz:secret-xyz").toString("base64")}`,
|
|
723
|
+
);
|
|
724
|
+
expect(params.get("client_id")).toBeNull();
|
|
725
|
+
expect(params.get("client_secret")).toBeNull();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("client_secret_post → client_id/client_secret in the body, no Authorization header", async () => {
|
|
729
|
+
const { params, headers } = await captureExchange("client_secret_post");
|
|
730
|
+
expect(headers.Authorization).toBeUndefined();
|
|
731
|
+
expect(params.get("client_id")).toBe("client-xyz");
|
|
732
|
+
expect(params.get("client_secret")).toBe("secret-xyz");
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("none → client_id only in the body, no secret anywhere", async () => {
|
|
736
|
+
const { params, headers } = await captureExchange("none");
|
|
737
|
+
expect(headers.Authorization).toBeUndefined();
|
|
738
|
+
expect(params.get("client_id")).toBe("client-xyz");
|
|
739
|
+
expect(params.get("client_secret")).toBeNull();
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("Basic credentials are form-urlencoded per RFC 6749 §2.3.1, not encodeURIComponent", async () => {
|
|
743
|
+
// A space form-encodes to "+", not "%20", and !'()* must be escaped.
|
|
744
|
+
// Getting this wrong sends a different secret than the AS stored, which
|
|
745
|
+
// surfaces as an opaque invalid_client at exchange, refresh and revoke.
|
|
746
|
+
const clientId = "client id!";
|
|
747
|
+
const clientSecret = "se cret*(a)";
|
|
748
|
+
const { headers } = await captureExchange("client_secret_basic", { clientId, clientSecret });
|
|
749
|
+
|
|
750
|
+
const decoded = Buffer.from(
|
|
751
|
+
(headers.Authorization ?? "").replace(/^Basic /, ""),
|
|
752
|
+
"base64",
|
|
753
|
+
).toString();
|
|
754
|
+
// `*` is intentionally literal: the urlencoded serializer preserves
|
|
755
|
+
// alphanumerics plus *-._ and encodes everything else.
|
|
756
|
+
expect(decoded).toBe("client+id%21:se+cret*%28a%29");
|
|
757
|
+
expect(decoded).not.toContain("%20");
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("a SHORT client secret is redacted even though registerVolatileSecret ignores it", async () => {
|
|
761
|
+
// registerVolatileSecret drops values under its minimum length, and RFC
|
|
762
|
+
// 7591 sets no floor on a client_secret, so redaction cannot rely on it.
|
|
763
|
+
const shortSecret = "s3cr3t";
|
|
764
|
+
globalThis.fetch = async () =>
|
|
765
|
+
new Response(`{"error":"invalid_client","detail":"bad secret ${shortSecret}"}`, {
|
|
766
|
+
status: 401,
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
await exchangeCodeForTokens({
|
|
770
|
+
tokenUrl: "https://as.example.com/token",
|
|
771
|
+
clientId: "client-xyz",
|
|
772
|
+
clientSecret: shortSecret,
|
|
773
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
774
|
+
redirectUri: "https://swarm.example.com/callback",
|
|
775
|
+
code: "authcode-1",
|
|
776
|
+
codeVerifier: "verifier-1",
|
|
777
|
+
resource: "https://mcp.example.com/",
|
|
778
|
+
}).catch((err: Error) => {
|
|
779
|
+
expect(err.message).toContain("Token exchange failed (401)");
|
|
780
|
+
expect(err.message).not.toContain(shortSecret);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("the code, verifier and encoded credential forms are redacted too", async () => {
|
|
785
|
+
const secret = "se cret with spaces";
|
|
786
|
+
const encodedSecret = new URLSearchParams({ v: secret }).toString().slice(2);
|
|
787
|
+
const code = "authcode-echoed";
|
|
788
|
+
const verifier = "verifier-echoed";
|
|
789
|
+
globalThis.fetch = async () =>
|
|
790
|
+
new Response(`{"detail":"${encodedSecret} ${code} ${verifier}"}`, { status: 400 });
|
|
791
|
+
|
|
792
|
+
await exchangeCodeForTokens({
|
|
793
|
+
tokenUrl: "https://as.example.com/token",
|
|
794
|
+
clientId: "client-xyz",
|
|
795
|
+
clientSecret: secret,
|
|
796
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
797
|
+
redirectUri: "https://swarm.example.com/callback",
|
|
798
|
+
code,
|
|
799
|
+
codeVerifier: verifier,
|
|
800
|
+
resource: "https://mcp.example.com/",
|
|
801
|
+
}).catch((err: Error) => {
|
|
802
|
+
expect(err.message).not.toContain(encodedSecret);
|
|
803
|
+
expect(err.message).not.toContain(code);
|
|
804
|
+
expect(err.message).not.toContain(verifier);
|
|
805
|
+
expect(err.message).not.toContain(secret);
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test("form-encoded code and verifier are redacted when they contain reserved characters", async () => {
|
|
810
|
+
// A base64ish code like "a+b/c=" travels as "a%2Bb%2Fc%3D". Redacting only
|
|
811
|
+
// the raw value leaves the echoed body untouched. Earlier tests used
|
|
812
|
+
// URL-safe values, so they could not catch this.
|
|
813
|
+
const code = "a+b/c=";
|
|
814
|
+
const verifier = "v+e/r=";
|
|
815
|
+
const encode = (v: string) => new URLSearchParams({ v }).toString().slice(2);
|
|
816
|
+
globalThis.fetch = async () =>
|
|
817
|
+
new Response(`{"detail":"code=${encode(code)}&code_verifier=${encode(verifier)}"}`, {
|
|
818
|
+
status: 400,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
await exchangeCodeForTokens({
|
|
822
|
+
tokenUrl: "https://as.example.com/token",
|
|
823
|
+
clientId: "client-xyz",
|
|
824
|
+
clientSecret: "secret-xyz",
|
|
825
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
826
|
+
redirectUri: "https://swarm.example.com/callback",
|
|
827
|
+
code,
|
|
828
|
+
codeVerifier: verifier,
|
|
829
|
+
resource: "https://mcp.example.com/",
|
|
830
|
+
}).catch((err: Error) => {
|
|
831
|
+
expect(err.message).not.toContain(encode(code));
|
|
832
|
+
expect(err.message).not.toContain(encode(verifier));
|
|
833
|
+
expect(err.message).not.toContain(code);
|
|
834
|
+
expect(err.message).not.toContain(verifier);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("a form-encoded refresh token is redacted on a failed refresh", async () => {
|
|
839
|
+
const refreshToken = "rt+slash/eq=";
|
|
840
|
+
const encoded = new URLSearchParams({ v: refreshToken }).toString().slice(2);
|
|
841
|
+
globalThis.fetch = async () =>
|
|
842
|
+
new Response(`{"detail":"refresh_token=${encoded}"}`, { status: 400 });
|
|
843
|
+
|
|
844
|
+
await refreshMcpToken({
|
|
845
|
+
tokenUrl: "https://as.example.com/token",
|
|
846
|
+
clientId: "client-xyz",
|
|
847
|
+
clientSecret: "secret-xyz",
|
|
848
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
849
|
+
refreshToken,
|
|
850
|
+
resource: "https://mcp.example.com/",
|
|
851
|
+
}).catch((err: Error) => {
|
|
852
|
+
expect(err.message).toContain("Token refresh failed (400)");
|
|
853
|
+
expect(err.message).not.toContain(encoded);
|
|
854
|
+
expect(err.message).not.toContain(refreshToken);
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test("a form-encoded revoked token is redacted on a failed revocation", async () => {
|
|
859
|
+
const token = "tok+slash/eq=";
|
|
860
|
+
const encoded = new URLSearchParams({ v: token }).toString().slice(2);
|
|
861
|
+
globalThis.fetch = async () => new Response(`{"detail":"token=${encoded}"}`, { status: 400 });
|
|
862
|
+
|
|
863
|
+
await revokeMcpToken({
|
|
864
|
+
revocationUrl: "https://as.example.com/revoke",
|
|
865
|
+
token,
|
|
866
|
+
clientId: "client-xyz",
|
|
867
|
+
clientSecret: "secret-xyz",
|
|
868
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
869
|
+
}).catch((err: Error) => {
|
|
870
|
+
expect(err.message).toContain("Token revocation failed (400)");
|
|
871
|
+
expect(err.message).not.toContain(encoded);
|
|
872
|
+
expect(err.message).not.toContain(token);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("an upstream error body echoing the client secret is scrubbed before it is thrown", async () => {
|
|
877
|
+
// The callback logs this message AND reflects it into the dashboard
|
|
878
|
+
// redirect as error_description, so an echoed credential would escape.
|
|
879
|
+
const secret = "sk-echoed-secret-value-1234567890";
|
|
880
|
+
globalThis.fetch = async () =>
|
|
881
|
+
new Response(`{"error":"invalid_client","detail":"bad client_secret: ${secret}"}`, {
|
|
882
|
+
status: 401,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
await expect(
|
|
886
|
+
exchangeCodeForTokens({
|
|
887
|
+
tokenUrl: "https://as.example.com/token",
|
|
888
|
+
clientId: "client-xyz",
|
|
889
|
+
clientSecret: secret,
|
|
890
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
891
|
+
redirectUri: "https://swarm.example.com/callback",
|
|
892
|
+
code: "authcode-1",
|
|
893
|
+
codeVerifier: "verifier-1",
|
|
894
|
+
resource: "https://mcp.example.com/",
|
|
895
|
+
}),
|
|
896
|
+
).rejects.toThrow(/Token exchange failed \(401\)/);
|
|
897
|
+
|
|
898
|
+
await exchangeCodeForTokens({
|
|
899
|
+
tokenUrl: "https://as.example.com/token",
|
|
900
|
+
clientId: "client-xyz",
|
|
901
|
+
clientSecret: secret,
|
|
902
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
903
|
+
redirectUri: "https://swarm.example.com/callback",
|
|
904
|
+
code: "authcode-1",
|
|
905
|
+
codeVerifier: "verifier-1",
|
|
906
|
+
resource: "https://mcp.example.com/",
|
|
907
|
+
}).catch((err: Error) => {
|
|
908
|
+
expect(err.message).not.toContain(secret);
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
test("a revocation error body echoing the token is scrubbed before it is thrown", async () => {
|
|
913
|
+
const token = "rt-echoed-refresh-token-0987654321";
|
|
914
|
+
globalThis.fetch = async () => new Response(`{"error":"bad token: ${token}"}`, { status: 400 });
|
|
915
|
+
|
|
916
|
+
await revokeMcpToken({
|
|
917
|
+
revocationUrl: "https://as.example.com/revoke",
|
|
918
|
+
token,
|
|
919
|
+
clientId: "client-xyz",
|
|
920
|
+
clientSecret: "secret-xyz",
|
|
921
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
922
|
+
}).catch((err: Error) => {
|
|
923
|
+
expect(err.message).toContain("Token revocation failed (400)");
|
|
924
|
+
expect(err.message).not.toContain(token);
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("missing/unknown value defaults to client_secret_basic, never the old body-post behavior", async () => {
|
|
929
|
+
for (const missing of [undefined, null, "some-unknown-method"]) {
|
|
930
|
+
const { params, headers } = await captureExchange(missing);
|
|
931
|
+
expect(headers.Authorization).toBe(
|
|
932
|
+
`Basic ${Buffer.from("client-xyz:secret-xyz").toString("base64")}`,
|
|
933
|
+
);
|
|
934
|
+
expect(params.get("client_id")).toBeNull();
|
|
935
|
+
expect(params.get("client_secret")).toBeNull();
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test("a public client recorded as basic identifies itself in the body", async () => {
|
|
940
|
+
let capturedBody = "";
|
|
941
|
+
let capturedHeaders: Record<string, string> = {};
|
|
942
|
+
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
|
|
943
|
+
capturedBody = (init?.body as string) ?? "";
|
|
944
|
+
capturedHeaders = (init?.headers as Record<string, string>) ?? {};
|
|
945
|
+
return new Response(JSON.stringify({ access_token: "at-1" }), {
|
|
946
|
+
status: 200,
|
|
947
|
+
headers: { "Content-Type": "application/json" },
|
|
948
|
+
});
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
await exchangeCodeForTokens({
|
|
952
|
+
tokenUrl: "https://as.example.com/token",
|
|
953
|
+
clientId: "public-client",
|
|
954
|
+
clientSecret: null,
|
|
955
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
956
|
+
redirectUri: "https://swarm.example.com/callback",
|
|
957
|
+
code: "authcode-1",
|
|
958
|
+
codeVerifier: "verifier-1",
|
|
959
|
+
resource: "https://mcp.example.com/",
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
const params = new URLSearchParams(capturedBody);
|
|
963
|
+
expect(capturedHeaders.Authorization).toBeUndefined();
|
|
964
|
+
expect(params.get("client_id")).toBe("public-client");
|
|
965
|
+
expect(params.get("client_secret")).toBeNull();
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test("refreshMcpToken applies the same rule on the refresh_token grant", async () => {
|
|
969
|
+
let capturedHeaders: Record<string, string> = {};
|
|
970
|
+
let capturedBody = "";
|
|
971
|
+
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
|
|
972
|
+
capturedBody = (init?.body as string) ?? "";
|
|
973
|
+
capturedHeaders = (init?.headers as Record<string, string>) ?? {};
|
|
974
|
+
return new Response(JSON.stringify({ access_token: "new-at" }), {
|
|
975
|
+
status: 200,
|
|
976
|
+
headers: { "Content-Type": "application/json" },
|
|
977
|
+
});
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
await refreshMcpToken({
|
|
981
|
+
tokenUrl: "https://as.example.com/token",
|
|
982
|
+
clientId: "client-xyz",
|
|
983
|
+
clientSecret: "secret-xyz",
|
|
984
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
985
|
+
refreshToken: "rt-abc",
|
|
986
|
+
resource: "https://mcp.example.com/",
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
const params = new URLSearchParams(capturedBody);
|
|
990
|
+
expect(capturedHeaders.Authorization).toBe(
|
|
991
|
+
`Basic ${Buffer.from("client-xyz:secret-xyz").toString("base64")}`,
|
|
992
|
+
);
|
|
993
|
+
expect(params.get("client_id")).toBeNull();
|
|
994
|
+
expect(params.get("client_secret")).toBeNull();
|
|
995
|
+
expect(params.get("grant_type")).toBe("refresh_token");
|
|
996
|
+
expect(params.get("refresh_token")).toBe("rt-abc");
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
|
|
568
1000
|
// ─── computeExpiresAt ────────────────────────────────────────────────────────
|
|
569
1001
|
|
|
570
1002
|
describe("computeExpiresAt", () => {
|
|
@@ -26,6 +26,7 @@ const memory: AgentMemory = {
|
|
|
26
26
|
name: "ui-memory-fixture",
|
|
27
27
|
scope: "agent",
|
|
28
28
|
source: "manual",
|
|
29
|
+
summary: null,
|
|
29
30
|
sourcePath: null,
|
|
30
31
|
sourceTaskId: null,
|
|
31
32
|
chunkIndex: 0,
|
|
@@ -34,6 +35,7 @@ const memory: AgentMemory = {
|
|
|
34
35
|
contextKey: null,
|
|
35
36
|
createdAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
|
|
36
37
|
updatedAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
|
|
38
|
+
accessedAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
mock.module("../be/memory", () => ({
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ServerResponse } from "node:http";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { auditRouteResponses } from "../../scripts/check-openapi-response-coverage";
|
|
5
|
+
import { route } from "../http/route-def";
|
|
6
|
+
|
|
7
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface Captured {
|
|
10
|
+
status?: number;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
body?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fakeRes(captured: Captured): ServerResponse {
|
|
16
|
+
return {
|
|
17
|
+
writeHead(status: number, headers: Record<string, string>) {
|
|
18
|
+
captured.status = status;
|
|
19
|
+
captured.headers = headers;
|
|
20
|
+
return this;
|
|
21
|
+
},
|
|
22
|
+
end(chunk?: unknown) {
|
|
23
|
+
captured.body = typeof chunk === "string" ? chunk : undefined;
|
|
24
|
+
return this;
|
|
25
|
+
},
|
|
26
|
+
} as unknown as ServerResponse;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const testRoute = route({
|
|
30
|
+
method: "get",
|
|
31
|
+
path: "/api/__respond-contract-test",
|
|
32
|
+
pattern: ["api", "__respond-contract-test"],
|
|
33
|
+
summary: "respond() contract fixture",
|
|
34
|
+
tags: ["Tests"],
|
|
35
|
+
responses: {
|
|
36
|
+
200: {
|
|
37
|
+
description: "ok",
|
|
38
|
+
schema: z.object({ name: z.string(), count: z.number().int() }),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ─── respond() egress contract ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe("route handle respond()", () => {
|
|
46
|
+
test("valid payload: exact status, JSON content-type, raw JSON.stringify body", () => {
|
|
47
|
+
const captured: Captured = {};
|
|
48
|
+
const payload = { name: "swarm", count: 3 };
|
|
49
|
+
testRoute.respond(fakeRes(captured), 200, payload);
|
|
50
|
+
|
|
51
|
+
expect(captured.status).toBe(200);
|
|
52
|
+
expect(captured.headers).toEqual({ "Content-Type": "application/json" });
|
|
53
|
+
// Wire bytes are the RAW payload, never the schema's parsed output.
|
|
54
|
+
expect(captured.body).toBe(JSON.stringify(payload));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("invalid payload throws a schema-violation error under bun test (NODE_ENV=test)", () => {
|
|
58
|
+
const captured: Captured = {};
|
|
59
|
+
expect(() =>
|
|
60
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberately violating the compile-time contract
|
|
61
|
+
testRoute.respond(fakeRes(captured), 200, { name: 42 } as any),
|
|
62
|
+
).toThrow(/Response schema violation: GET \/api\/__respond-contract-test 200/);
|
|
63
|
+
// Nothing was written — the throw happens before writeHead.
|
|
64
|
+
expect(captured.status).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("validation is OFF when NODE_ENV is unset (production posture): invalid payload still sends", () => {
|
|
68
|
+
// VALIDATE_RESPONSES is baked at module load, so probe in a subprocess
|
|
69
|
+
// with a production-like env (NODE_ENV deliberately unset on deploys).
|
|
70
|
+
const probe = `
|
|
71
|
+
import { z } from "zod";
|
|
72
|
+
import { route } from "${import.meta.dir}/../http/route-def";
|
|
73
|
+
const r = route({
|
|
74
|
+
method: "get", path: "/probe", pattern: ["probe"], summary: "p", tags: ["t"],
|
|
75
|
+
responses: { 200: { description: "ok", schema: z.object({ n: z.number() }) } },
|
|
76
|
+
});
|
|
77
|
+
let status = 0, body = "";
|
|
78
|
+
r.respond({ writeHead(s) { status = s; return this; }, end(b) { body = b; return this; } }, 200, { n: "not-a-number" });
|
|
79
|
+
console.log(JSON.stringify({ status, body }));
|
|
80
|
+
`;
|
|
81
|
+
const env = { ...process.env };
|
|
82
|
+
delete env.NODE_ENV;
|
|
83
|
+
delete env.VALIDATE_HTTP_RESPONSES;
|
|
84
|
+
const proc = Bun.spawnSync(["bun", "-e", probe], { env });
|
|
85
|
+
expect(proc.exitCode).toBe(0);
|
|
86
|
+
const out = JSON.parse(proc.stdout.toString().trim());
|
|
87
|
+
expect(out.status).toBe(200);
|
|
88
|
+
expect(out.body).toBe(JSON.stringify({ n: "not-a-number" }));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─── coverage-gate audit (per-status regression) ─────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("auditRouteResponses", () => {
|
|
95
|
+
test("a NEW undecided 2xx on a partially-covered route is reported per status code", () => {
|
|
96
|
+
// Regression for the route-level-exemption hole (codex review, PR #1141):
|
|
97
|
+
// a backlogged/covered route gaining a fresh untyped 201 must surface it.
|
|
98
|
+
const { undecided } = auditRouteResponses([
|
|
99
|
+
{
|
|
100
|
+
method: "post",
|
|
101
|
+
path: "/api/things",
|
|
102
|
+
responses: {
|
|
103
|
+
200: { description: "ok", schema: z.object({}) },
|
|
104
|
+
201: { description: "created" }, // no decision
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
] as never);
|
|
108
|
+
expect(undecided).toEqual(["POST /api/things 201"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("bodiless 204/205 and non-2xx codes need no decision; unstructured counts as decided", () => {
|
|
112
|
+
const { undecided } = auditRouteResponses([
|
|
113
|
+
{
|
|
114
|
+
method: "get",
|
|
115
|
+
path: "/api/stream",
|
|
116
|
+
responses: {
|
|
117
|
+
200: { description: "sse", unstructured: "SSE stream" },
|
|
118
|
+
204: { description: "no content" },
|
|
119
|
+
404: { description: "not found" },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
] as never);
|
|
123
|
+
expect(undecided).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("schema + unstructured on the same response is a contradiction", () => {
|
|
127
|
+
const { contradictory } = auditRouteResponses([
|
|
128
|
+
{
|
|
129
|
+
method: "get",
|
|
130
|
+
path: "/api/confused",
|
|
131
|
+
responses: {
|
|
132
|
+
200: { description: "??", schema: z.object({}), unstructured: "also raw" },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
] as never);
|
|
136
|
+
expect(contradictory).toEqual(["GET /api/confused 200"]);
|
|
137
|
+
});
|
|
138
|
+
});
|