@desplega.ai/agent-swarm 1.113.0 → 1.115.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-q4n7cz6e.js → actions-17ya5wde.js} +8 -9
- package/dist/{app-es4nzc71.js → app-4f718yk0.js} +6 -6
- package/dist/{assistant-0x8ey0vs.js → assistant-ah7csnjz.js} +19 -18
- package/dist/{boot-reembed-69agkhv6.js → boot-reembed-4jpxhb25.js} +6 -6
- package/dist/{boot-reembed-p0ny05t8.js → boot-reembed-qata34xa.js} +5 -5
- package/dist/{boot-scrub-logs-yybrkmvr.js → boot-scrub-logs-tv48nn3z.js} +4 -4
- package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-3csn5bks.js} +3 -3
- package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
- package/dist/cli-0pygq9r1.js +429 -0
- package/dist/{cli-hsetkkd3.js → cli-12vb0xg6.js} +33 -9
- package/dist/{cli-ncfgsqbd.js → cli-15y7qhzp.js} +262 -33
- package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
- package/dist/{cli-36ymmpmp.js → cli-3samkyap.js} +2 -2
- package/dist/cli-52fg7s7x.js +269 -0
- package/dist/{cli-hjhvekf4.js → cli-65ekje01.js} +11 -8
- package/dist/{cli-h3k622d0.js → cli-6aq8xhvm.js} +90 -3
- package/dist/{cli-f3qa069v.js → cli-7xje5amz.js} +1 -1
- package/dist/cli-86fdnerz.js +405 -0
- package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
- package/dist/{cli-c3frqf65.js → cli-b23axgz4.js} +3 -3
- package/dist/{cli-a0t7vapf.js → cli-bc89my0z.js} +1 -1
- package/dist/{cli-bnzbn8j8.js → cli-beea4a3y.js} +73 -24
- package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
- package/dist/{cli-pwc5e83c.js → cli-cchksf5q.js} +12 -7
- package/dist/{cli-30bbaveh.js → cli-cmw9rzy5.js} +2 -2
- package/dist/{cli-y6mrptcn.js → cli-cnwssmsr.js} +1 -1
- package/dist/{cli-r5g6ngtn.js → cli-dcca6j8m.js} +46 -5
- package/dist/{cli-tkmm5nf9.js → cli-e61fs2hp.js} +59 -2
- package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
- package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
- package/dist/{cli-4xyj9jtq.js → cli-hrhye2rm.js} +1062 -478
- package/dist/cli-jkhwmmgg.js +136 -0
- package/dist/{cli-y4ycrnjc.js → cli-k3y4z3h5.js} +1 -1
- package/dist/{cli-6xd0bvf8.js → cli-ma04faw5.js} +1 -1
- package/dist/{cli-2fc97xm5.js → cli-mnbgv3w7.js} +4 -4
- package/dist/{cli-trmapn9k.js → cli-pet3aev6.js} +95 -10
- package/dist/{cli-z46pnksz.js → cli-q1rx7g0d.js} +175 -28
- package/dist/{cli-qwgtacd6.js → cli-r9f6ngrf.js} +1244 -336
- package/dist/{cli-gtkqc144.js → cli-shvaxw27.js} +13 -7
- package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
- package/dist/cli-t0vqevnm.js +393 -0
- package/dist/{cli-tbw13sx8.js → cli-tece6zgm.js} +1 -1
- package/dist/{cli-bgvskydy.js → cli-tffvrh6j.js} +1 -1
- package/dist/cli-wh91zby6.js +187222 -0
- package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
- package/dist/{cli-mq580e06.js → cli-zsrdty74.js} +18 -18
- package/dist/cli.js +32 -15
- package/dist/{codex-adapter-5cmy858w.js → codex-adapter-de3w0nch.js} +10 -10
- package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-bnw8cysk.js} +10 -10
- package/dist/{commands-z5dbwta3.js → commands-hch4e2k1.js} +4 -4
- package/dist/{db-bbgahh4z.js → db-p3h2j06x.js} +14 -4
- package/dist/{handlers-6b44317z.js → handlers-9er4zx9q.js} +17 -31
- package/dist/{hook-sbp5fmps.js → hook-4axs756z.js} +7 -7
- package/dist/{http-2xz43gz3.js → http-809fvtz2.js} +1934 -886
- package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
- package/dist/{index-nhat35em.js → index-47em3d2n.js} +14 -11
- package/dist/{index-5ghxn8s6.js → index-9fqaktjr.js} +12 -9
- package/dist/{index-0gzmqj4k.js → index-9ht8gr56.js} +13 -10
- package/dist/{index-vfedgdw6.js → index-rm85cfdx.js} +368 -40
- package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
- package/dist/{keepalive-62gfj91d.js → keepalive-3cps6e3e.js} +7 -6
- package/dist/{lead-ag0akn0v.js → lead-fkk95wgq.js} +36 -32
- package/dist/{maintenance-xxrrzk5x.js → maintenance-fweqh0yj.js} +6 -6
- package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
- package/dist/oauth-refresh-sweep-sycqtbph.js +114 -0
- package/dist/{onboard-64zyfcbs.js → onboard-cy0rkgvx.js} +2 -2
- package/dist/{opencode-adapter-mqsxr170.js → opencode-adapter-p6bxnbm4.js} +2 -2
- package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
- package/dist/{otel-impl-b86qseaa.js → otel-impl-wbns8hf7.js} +1 -1
- package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-05sck1ds.js} +17 -109
- package/dist/{pricing-refresh-cgtkffpa.js → pricing-refresh-0hvazmkf.js} +6 -6
- package/dist/rbac-roles-kfesjy9y.js +28 -0
- package/dist/rbac-roles-mwcwhbxc.js +283 -0
- package/dist/{seed-pricing-1r1y1xkq.js → seed-pricing-h2kdq4cq.js} +5 -5
- package/dist/{setup-mkcgkjf7.js → setup-8506q97w.js} +2 -2
- package/dist/{worker-351sze63.js → worker-4r5c6zyb.js} +36 -32
- package/openapi.json +1327 -20
- package/package.json +3 -3
- package/src/agentmail/handlers.ts +16 -6
- package/src/agentmail/templates.ts +25 -5
- package/src/be/audit-user.ts +28 -4
- package/src/be/db-queries/oauth.ts +42 -0
- package/src/be/db.ts +314 -28
- package/src/be/identity.ts +79 -0
- package/src/be/mcp-proxy.ts +132 -0
- package/src/be/migrations/109_rbac_roles.sql +79 -0
- package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
- package/src/be/migrations/112_script_connections_graphql.sql +96 -0
- package/src/be/migrations/113_task_routing_affinity.sql +9 -0
- package/src/be/migrations/114_backfill_gpt_5_6_pricing.sql +15 -0
- package/src/be/modelsdev-cache.json +135 -0
- package/src/be/oauth-credential-bindings.ts +35 -0
- package/src/be/oauth-refresh-sweep.ts +159 -0
- package/src/be/rbac-audit.ts +52 -13
- package/src/be/rbac-roles.ts +424 -0
- package/src/be/script-connections.ts +459 -40
- package/src/be/script-credential-broker.ts +29 -3
- package/src/be/scripts/typecheck.ts +51 -7
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +325 -24
- package/src/be/seed-scripts/catalog/catalog-report.ts +325 -24
- package/src/be/users.ts +24 -0
- package/src/cli.tsx +17 -0
- package/src/github/handlers.ts +10 -9
- package/src/github/templates.ts +45 -9
- package/src/gitlab/handlers.ts +20 -8
- package/src/gitlab/templates.ts +18 -6
- package/src/heartbeat/heartbeat.ts +143 -21
- package/src/http/all-routes.ts +3 -0
- package/src/http/approval-requests.ts +12 -0
- package/src/http/config.ts +44 -10
- package/src/http/core.ts +30 -3
- package/src/http/favorites.ts +1 -5
- package/src/http/index.ts +25 -1
- package/src/http/mcp-oauth.ts +2 -0
- package/src/http/mcp-servers.ts +44 -3
- package/src/http/oauth-generic.ts +105 -0
- package/src/http/poll.ts +20 -3
- package/src/http/script-connection-proxy.ts +116 -0
- package/src/http/script-connections.ts +1657 -0
- package/src/http/scripts.ts +27 -7
- package/src/http/skills.ts +11 -0
- package/src/http/tasks.ts +27 -13
- package/src/http/x.ts +6 -2
- package/src/integrations/kapso/inbound.ts +23 -6
- package/src/jira/sync.ts +111 -17
- package/src/jira/templates.ts +10 -2
- package/src/mcp-client/http-client.ts +194 -0
- package/src/oauth/app-validation.ts +16 -0
- package/src/oauth/ensure-token.ts +69 -14
- package/src/oauth/mcp-wrapper.ts +15 -0
- package/src/oauth/wrapper.ts +48 -20
- package/src/prompts/session-templates.ts +3 -3
- package/src/providers/codex-models.ts +100 -30
- package/src/providers/modelsdev-reasoning.json +51 -0
- package/src/providers/pi-mono-mcp-client.ts +2 -133
- package/src/providers/reasoning-effort.ts +11 -5
- package/src/rbac/admission.ts +68 -0
- package/src/rbac/index.ts +2 -0
- package/src/rbac/legacy-policy.ts +12 -0
- package/src/rbac/permissions.ts +48 -0
- package/src/scheduler/scheduler.ts +9 -1
- package/src/scripts-runtime/api-client.ts +151 -19
- package/src/scripts-runtime/api-types.ts +38 -6
- package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
- package/src/scripts-runtime/credential-broker/types.ts +8 -0
- package/src/scripts-runtime/ctx.ts +11 -1
- package/src/scripts-runtime/eval-harness.ts +5 -1
- package/src/scripts-runtime/executors/types.ts +2 -1
- package/src/scripts-runtime/loader.ts +3 -1
- package/src/scripts-runtime/mcp-client.ts +87 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +6 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +19 -0
- package/src/server-user.ts +114 -59
- package/src/server.ts +12 -0
- package/src/slack/assistant.ts +14 -6
- package/src/slack/enrich.ts +17 -0
- package/src/slack/handlers.ts +8 -22
- package/src/slack/responses.ts +91 -1
- package/src/slack/thread-buffer.ts +6 -3
- package/src/slack/watcher.ts +9 -6
- package/src/tasks/worker-follow-up.ts +100 -3
- package/src/tests/agentmail-handlers.test.ts +32 -3
- package/src/tests/approval-requests.test.ts +61 -0
- package/src/tests/audit-user.test.ts +47 -1
- package/src/tests/codex-adapter.test.ts +46 -23
- package/src/tests/credential-broker.test.ts +5 -2
- package/src/tests/delete-page-tool.test.ts +197 -0
- package/src/tests/github-handlers.test.ts +28 -2
- package/src/tests/gitlab-handlers.test.ts +125 -0
- package/src/tests/heartbeat.test.ts +48 -3
- package/src/tests/identity.test.ts +117 -0
- package/src/tests/jira-sync.test.ts +99 -1
- package/src/tests/kapso-inbound.test.ts +7 -0
- package/src/tests/mcp-tools-user.test.ts +60 -6
- package/src/tests/model-control.test.ts +8 -2
- package/src/tests/oauth-credential-bindings.test.ts +490 -0
- package/src/tests/oauth-refresh-sweep.test.ts +160 -0
- package/src/tests/pool-affinity.test.ts +401 -0
- package/src/tests/prompt-template-remaining.test.ts +2 -2
- package/src/tests/rbac-admission-e2e.test.ts +386 -0
- package/src/tests/rbac-admission.test.ts +492 -0
- package/src/tests/rbac-e2e-helpers.ts +3 -0
- package/src/tests/rbac-engine.test.ts +50 -0
- package/src/tests/rbac-mcp-admission-e2e.test.ts +362 -0
- package/src/tests/rbac-roles.test.ts +328 -0
- package/src/tests/reasoning-effort.test.ts +15 -2
- package/src/tests/schedule-target-type.test.ts +50 -0
- package/src/tests/scheduled-tasks.test.ts +40 -0
- package/src/tests/script-apis-mcp.test.ts +37 -13
- package/src/tests/script-connections-http.test.ts +1158 -0
- package/src/tests/script-connections-mcp.test.ts +894 -0
- package/src/tests/script-connections.test.ts +754 -6
- package/src/tests/scripts-external-api.test.ts +49 -1
- package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
- package/src/tests/scripts-runtime.test.ts +291 -0
- package/src/tests/seed-scripts.test.ts +36 -0
- package/src/tests/slack-identity-resolution.test.ts +37 -3
- package/src/tests/slack-inline-output.test.ts +65 -0
- package/src/tests/slack-thread-buffer.test.ts +28 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tests/user-token-rest-auth.test.ts +140 -1
- package/src/tests/workflow-swarm-script.test.ts +44 -0
- package/src/tests/workflow-triggers-v2.test.ts +300 -1
- package/src/tools/credential-bindings/tool.ts +277 -18
- package/src/tools/delete-page.ts +145 -0
- package/src/tools/mcp-servers/mcp-server-delete.ts +7 -4
- package/src/tools/request-human-input.ts +2 -0
- package/src/tools/resolve-user.ts +77 -27
- package/src/tools/script-apis.ts +62 -3
- package/src/tools/script-connections/tool.ts +275 -31
- package/src/tools/send-task.ts +15 -1
- package/src/tools/task-action.ts +34 -2
- package/src/tools/templates.ts +53 -1
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/utils.ts +2 -0
- package/src/tools/workflows/create-workflow.ts +8 -1
- package/src/tools/workflows/update-workflow.ts +11 -2
- package/src/types.ts +90 -11
- package/src/utils/scoped-resource.ts +25 -0
- package/src/workflows/executors/human-in-the-loop.ts +1 -0
- package/src/workflows/executors/swarm-script.ts +7 -1
- package/src/workflows/triggers.ts +146 -18
- package/templates/skills/swarm-scripts/content.md +33 -8
- package/dist/cli-bgef578c.js +0 -771
- package/dist/cli-c9mtm09b.js +0 -43
- package/dist/cli-ceewqajw.js +0 -1698
- package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
- package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
- package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
- package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
- package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
- package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
- package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
- package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
- package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { closeDb, createAgent, getDb, initDb, upsertSwarmConfig } from "../be/db";
|
|
6
|
+
import {
|
|
7
|
+
getOAuthApp,
|
|
8
|
+
getOAuthTokens,
|
|
9
|
+
storeOAuthTokens,
|
|
10
|
+
upsertOAuthApp,
|
|
11
|
+
} from "../be/db-queries/oauth";
|
|
12
|
+
import {
|
|
13
|
+
getScriptConnectionById,
|
|
14
|
+
setOpenapiSpecFetchForTesting,
|
|
15
|
+
setScriptConnectionEnabled,
|
|
16
|
+
upsertCredentialBinding,
|
|
17
|
+
upsertScriptConnection,
|
|
18
|
+
} from "../be/script-connections";
|
|
19
|
+
import { handleScriptConnections } from "../http/script-connections";
|
|
20
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
21
|
+
|
|
22
|
+
const TEST_DB_PATH = "./test-script-connections-http.sqlite";
|
|
23
|
+
const SECRET_VALUE = "vendor-secret-should-not-leak";
|
|
24
|
+
|
|
25
|
+
let leadAgentId: string;
|
|
26
|
+
let workerAgentId: string;
|
|
27
|
+
const originalFetch = globalThis.fetch;
|
|
28
|
+
|
|
29
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
30
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
31
|
+
try {
|
|
32
|
+
await unlink(path + suffix);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type TestResponse = {
|
|
40
|
+
status: number;
|
|
41
|
+
text: string;
|
|
42
|
+
json: () => Promise<unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function dispatch(
|
|
46
|
+
path: string,
|
|
47
|
+
init: { method?: string; body?: unknown; agentId?: string } = {},
|
|
48
|
+
): Promise<TestResponse> {
|
|
49
|
+
const req = Readable.from(
|
|
50
|
+
init.body === undefined ? [] : [Buffer.from(JSON.stringify(init.body))],
|
|
51
|
+
) as IncomingMessage;
|
|
52
|
+
req.method = init.method ?? "GET";
|
|
53
|
+
req.url = path;
|
|
54
|
+
req.headers = init.agentId
|
|
55
|
+
? {
|
|
56
|
+
"x-agent-id": init.agentId,
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
}
|
|
59
|
+
: { "content-type": "application/json" };
|
|
60
|
+
|
|
61
|
+
let status = 200;
|
|
62
|
+
let text = "";
|
|
63
|
+
const res = {
|
|
64
|
+
headersSent: false,
|
|
65
|
+
writableEnded: false,
|
|
66
|
+
setHeader() {},
|
|
67
|
+
writeHead(code: number) {
|
|
68
|
+
status = code;
|
|
69
|
+
this.headersSent = true;
|
|
70
|
+
return this;
|
|
71
|
+
},
|
|
72
|
+
end(chunk?: unknown) {
|
|
73
|
+
if (chunk !== undefined) text += String(chunk);
|
|
74
|
+
this.writableEnded = true;
|
|
75
|
+
return this;
|
|
76
|
+
},
|
|
77
|
+
} as unknown as ServerResponse;
|
|
78
|
+
|
|
79
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
80
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
81
|
+
if (!(await handleScriptConnections(req, res, pathSegments, queryParams, init.agentId))) {
|
|
82
|
+
res.writeHead(404);
|
|
83
|
+
res.end("Not Found");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
status,
|
|
88
|
+
text,
|
|
89
|
+
json: async () => JSON.parse(text),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function inlineOpenApiSpec(): string {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
openapi: "3.0.0",
|
|
96
|
+
info: { title: "Vendor", version: "1.0.0" },
|
|
97
|
+
paths: {
|
|
98
|
+
"/items": {
|
|
99
|
+
get: {
|
|
100
|
+
operationId: "listItems",
|
|
101
|
+
responses: {
|
|
102
|
+
"200": {
|
|
103
|
+
description: "OK",
|
|
104
|
+
content: {
|
|
105
|
+
"application/json": {
|
|
106
|
+
schema: {
|
|
107
|
+
type: "object",
|
|
108
|
+
properties: { ok: { type: "boolean" } },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
beforeAll(async () => {
|
|
121
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
122
|
+
initDb(TEST_DB_PATH);
|
|
123
|
+
leadAgentId = createAgent({ name: "connections-http-lead", isLead: true, status: "idle" }).id;
|
|
124
|
+
workerAgentId = createAgent({
|
|
125
|
+
name: "connections-http-worker",
|
|
126
|
+
isLead: false,
|
|
127
|
+
status: "idle",
|
|
128
|
+
}).id;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterAll(async () => {
|
|
132
|
+
globalThis.fetch = originalFetch;
|
|
133
|
+
setOpenapiSpecFetchForTesting(null);
|
|
134
|
+
closeDb();
|
|
135
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
globalThis.fetch = originalFetch;
|
|
140
|
+
setOpenapiSpecFetchForTesting(null);
|
|
141
|
+
getDb().run("DELETE FROM script_connections");
|
|
142
|
+
getDb().run("DELETE FROM script_credential_bindings");
|
|
143
|
+
getDb().run("DELETE FROM oauth_tokens");
|
|
144
|
+
getDb().run("DELETE FROM oauth_apps");
|
|
145
|
+
getDb().run("DELETE FROM swarm_config");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("/api/script-connections HTTP", () => {
|
|
149
|
+
test("POST upsert openapi inline spec succeeds as lead agent", async () => {
|
|
150
|
+
const res = await dispatch("/api/script-connections", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
agentId: leadAgentId,
|
|
153
|
+
body: {
|
|
154
|
+
kind: "openapi",
|
|
155
|
+
slug: "vendor",
|
|
156
|
+
displayName: "Vendor",
|
|
157
|
+
baseUrl: "https://api.vendor.test",
|
|
158
|
+
allowedHosts: ["api.vendor.test"],
|
|
159
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(res.status).toBe(200);
|
|
164
|
+
const body = (await res.json()) as {
|
|
165
|
+
connection: { slug: string; kind: string; operationCount: number };
|
|
166
|
+
};
|
|
167
|
+
expect(body.connection.slug).toBe("vendor");
|
|
168
|
+
expect(body.connection.kind).toBe("openapi");
|
|
169
|
+
expect(body.connection.operationCount).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("POST upsert with configKey and queryTemplate creates a query-only binding", async () => {
|
|
173
|
+
const res = await dispatch("/api/script-connections", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
agentId: leadAgentId,
|
|
176
|
+
body: {
|
|
177
|
+
kind: "openapi",
|
|
178
|
+
slug: "queryAuthVendor",
|
|
179
|
+
baseUrl: "https://api.vendor.test",
|
|
180
|
+
allowedHosts: ["api.vendor.test"],
|
|
181
|
+
configKey: "QUERY_AUTH_VENDOR_KEY",
|
|
182
|
+
queryTemplate: "api_key=[REDACTED:QUERY_AUTH_VENDOR_KEY]",
|
|
183
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(res.status).toBe(200);
|
|
188
|
+
const body = (await res.json()) as { connection: { id: string } };
|
|
189
|
+
const bindingRow = getDb()
|
|
190
|
+
.prepare<{ header_template: string | null; query_template: string | null }, [string]>(
|
|
191
|
+
`SELECT b.header_template, b.query_template
|
|
192
|
+
FROM script_credential_bindings b
|
|
193
|
+
JOIN script_connections c ON c.credential_binding_id = b.id
|
|
194
|
+
WHERE c.id = ?`,
|
|
195
|
+
)
|
|
196
|
+
.get(body.connection.id);
|
|
197
|
+
|
|
198
|
+
expect(bindingRow?.header_template).toBeNull();
|
|
199
|
+
expect(bindingRow?.query_template).toBe("api_key=[REDACTED:QUERY_AUTH_VENDOR_KEY]");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("POST upsert is forbidden for non-lead agent principal", async () => {
|
|
203
|
+
const res = await dispatch("/api/script-connections", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
agentId: workerAgentId,
|
|
206
|
+
body: {
|
|
207
|
+
kind: "openapi",
|
|
208
|
+
slug: "blockedVendor",
|
|
209
|
+
baseUrl: "https://api.vendor.test",
|
|
210
|
+
allowedHosts: ["api.vendor.test"],
|
|
211
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(res.status).toBe(403);
|
|
216
|
+
expect(await res.json()).toEqual({ error: "Only the lead can manage script connections." });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("list returns connections without secrets", async () => {
|
|
220
|
+
upsertSwarmConfig({
|
|
221
|
+
scope: "global",
|
|
222
|
+
key: "VENDOR_TOKEN",
|
|
223
|
+
value: SECRET_VALUE,
|
|
224
|
+
isSecret: true,
|
|
225
|
+
});
|
|
226
|
+
const binding = upsertCredentialBinding({
|
|
227
|
+
configKey: "VENDOR_TOKEN",
|
|
228
|
+
allowedHosts: ["api.vendor.test"],
|
|
229
|
+
headerTemplate: "Authorization: Bearer [REDACTED:VENDOR_TOKEN]",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await dispatch("/api/script-connections", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
agentId: leadAgentId,
|
|
235
|
+
body: {
|
|
236
|
+
kind: "openapi",
|
|
237
|
+
slug: "vendor",
|
|
238
|
+
baseUrl: "https://api.vendor.test",
|
|
239
|
+
allowedHosts: ["api.vendor.test"],
|
|
240
|
+
credentialBindingId: binding.id,
|
|
241
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const res = await dispatch("/api/script-connections");
|
|
246
|
+
expect(res.status).toBe(200);
|
|
247
|
+
const body = (await res.json()) as {
|
|
248
|
+
connections: Array<{
|
|
249
|
+
credentialBinding: { configKey: string } | null;
|
|
250
|
+
openapiSpecJson?: string;
|
|
251
|
+
generatedRuntimeJson?: string;
|
|
252
|
+
generatedTypes?: string;
|
|
253
|
+
}>;
|
|
254
|
+
};
|
|
255
|
+
expect(body.connections).toHaveLength(1);
|
|
256
|
+
expect(body.connections[0]?.credentialBinding?.configKey).toBe("VENDOR_TOKEN");
|
|
257
|
+
expect(body.connections[0]?.openapiSpecJson).toBeUndefined();
|
|
258
|
+
expect(body.connections[0]?.generatedRuntimeJson).toBeUndefined();
|
|
259
|
+
expect(body.connections[0]?.generatedTypes).toBeUndefined();
|
|
260
|
+
expect(JSON.stringify(body)).not.toContain(SECRET_VALUE);
|
|
261
|
+
expect(JSON.stringify(body)).not.toContain("[REDACTED:VENDOR_TOKEN]");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("unfiltered dashboard list includes agent-scoped connections", async () => {
|
|
265
|
+
const connection = await upsertScriptConnection({
|
|
266
|
+
slug: "agentScopedVisible",
|
|
267
|
+
kind: "openapi",
|
|
268
|
+
scope: "agent",
|
|
269
|
+
scopeId: workerAgentId,
|
|
270
|
+
baseUrl: "https://api.vendor.test",
|
|
271
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const res = await dispatch("/api/script-connections");
|
|
275
|
+
|
|
276
|
+
expect(res.status).toBe(200);
|
|
277
|
+
const body = (await res.json()) as { connections: Array<{ id: string; scope: string }> };
|
|
278
|
+
expect(body.connections).toContainEqual(
|
|
279
|
+
expect.objectContaining({ id: connection.id, scope: "agent" }),
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("id update preserves omitted scope and disabled state", async () => {
|
|
284
|
+
const connection = await upsertScriptConnection({
|
|
285
|
+
slug: "agentScopedDisabled",
|
|
286
|
+
displayName: "Original",
|
|
287
|
+
kind: "openapi",
|
|
288
|
+
scope: "agent",
|
|
289
|
+
scopeId: workerAgentId,
|
|
290
|
+
baseUrl: "https://api.vendor.test",
|
|
291
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
292
|
+
enabled: false,
|
|
293
|
+
});
|
|
294
|
+
setScriptConnectionEnabled(connection.id, false);
|
|
295
|
+
|
|
296
|
+
const res = await dispatch("/api/script-connections", {
|
|
297
|
+
method: "POST",
|
|
298
|
+
agentId: leadAgentId,
|
|
299
|
+
body: {
|
|
300
|
+
id: connection.id,
|
|
301
|
+
kind: "openapi",
|
|
302
|
+
slug: connection.slug,
|
|
303
|
+
displayName: "Renamed",
|
|
304
|
+
baseUrl: "https://api.vendor.test",
|
|
305
|
+
allowedHosts: ["api.vendor.test"],
|
|
306
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(res.status).toBe(200);
|
|
311
|
+
const stored = getScriptConnectionById(connection.id);
|
|
312
|
+
expect(stored?.displayName).toBe("Renamed");
|
|
313
|
+
expect(stored?.scope).toBe("agent");
|
|
314
|
+
expect(stored?.scopeId).toBe(workerAgentId);
|
|
315
|
+
expect(stored?.enabled).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("repo-scoped connection upsert accepts owner/name scope IDs", async () => {
|
|
319
|
+
const res = await dispatch("/api/script-connections", {
|
|
320
|
+
method: "POST",
|
|
321
|
+
agentId: leadAgentId,
|
|
322
|
+
body: {
|
|
323
|
+
kind: "openapi",
|
|
324
|
+
slug: "repoScopedVendor",
|
|
325
|
+
baseUrl: "https://api.vendor.test",
|
|
326
|
+
allowedHosts: ["api.vendor.test"],
|
|
327
|
+
scope: "repo",
|
|
328
|
+
scopeId: "desplega-ai/agent-swarm",
|
|
329
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
expect(res.status).toBe(200);
|
|
334
|
+
const body = (await res.json()) as {
|
|
335
|
+
connection: { scope: string; scopeId: string | null };
|
|
336
|
+
};
|
|
337
|
+
expect(body.connection.scope).toBe("repo");
|
|
338
|
+
expect(body.connection.scopeId).toBe("desplega-ai/agent-swarm");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("id update with a changed OpenAPI spec URL fetches the new URL", async () => {
|
|
342
|
+
const firstUrl = "https://spec.vendor.test/one.json";
|
|
343
|
+
const secondUrl = "https://spec.vendor.test/two.json";
|
|
344
|
+
const requests: string[] = [];
|
|
345
|
+
setOpenapiSpecFetchForTesting((async (input) => {
|
|
346
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
347
|
+
requests.push(url);
|
|
348
|
+
return new Response(inlineOpenApiSpec(), {
|
|
349
|
+
status: 200,
|
|
350
|
+
headers: { etag: url === firstUrl ? '"one"' : '"two"' },
|
|
351
|
+
});
|
|
352
|
+
}) as typeof fetch);
|
|
353
|
+
|
|
354
|
+
const created = await dispatch("/api/script-connections", {
|
|
355
|
+
method: "POST",
|
|
356
|
+
agentId: leadAgentId,
|
|
357
|
+
body: {
|
|
358
|
+
kind: "openapi",
|
|
359
|
+
slug: "urlEditVendor",
|
|
360
|
+
baseUrl: "https://api.vendor.test",
|
|
361
|
+
allowedHosts: ["api.vendor.test"],
|
|
362
|
+
openapiSpecUrl: firstUrl,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
expect(created.status).toBe(200);
|
|
366
|
+
const createdBody = (await created.json()) as { connection: { id: string; slug: string } };
|
|
367
|
+
|
|
368
|
+
const updated = await dispatch("/api/script-connections", {
|
|
369
|
+
method: "POST",
|
|
370
|
+
agentId: leadAgentId,
|
|
371
|
+
body: {
|
|
372
|
+
id: createdBody.connection.id,
|
|
373
|
+
kind: "openapi",
|
|
374
|
+
slug: createdBody.connection.slug,
|
|
375
|
+
displayName: "URL changed",
|
|
376
|
+
baseUrl: "https://api.vendor.test",
|
|
377
|
+
allowedHosts: ["api.vendor.test"],
|
|
378
|
+
openapiSpecUrl: secondUrl,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(updated.status).toBe(200);
|
|
383
|
+
expect(requests).toEqual([firstUrl, secondUrl]);
|
|
384
|
+
const stored = getScriptConnectionById(createdBody.connection.id);
|
|
385
|
+
expect(stored?.openapiSpecSource).toBe(secondUrl);
|
|
386
|
+
expect(stored?.openapiSpecEtag).toBe('"two"');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("oauth-apps GET never includes clientSecret", async () => {
|
|
390
|
+
upsertOAuthApp("vendor_oauth", {
|
|
391
|
+
clientId: "vendor-client",
|
|
392
|
+
clientSecret: "oauth-client-secret-should-not-leak",
|
|
393
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
394
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
395
|
+
redirectUri: "https://api.public.test/api/oauth/vendor_oauth/callback",
|
|
396
|
+
scopes: "read,write",
|
|
397
|
+
metadata: JSON.stringify({
|
|
398
|
+
extraParams: { audience: "vendor" },
|
|
399
|
+
tokenAuthStyle: "basic",
|
|
400
|
+
tokenBodyFormat: "json",
|
|
401
|
+
}),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const res = await dispatch("/api/oauth-apps");
|
|
405
|
+
expect(res.status).toBe(200);
|
|
406
|
+
const body = (await res.json()) as { oauthApps: Array<Record<string, unknown>> };
|
|
407
|
+
expect(body.oauthApps).toHaveLength(1);
|
|
408
|
+
expect(body.oauthApps[0]?.provider).toBe("vendor_oauth");
|
|
409
|
+
expect(body.oauthApps[0]?.clientId).toBe("vendor-client");
|
|
410
|
+
expect(body.oauthApps[0]).not.toHaveProperty("clientSecret");
|
|
411
|
+
expect(body.oauthApps[0]?.lastRefreshedAt).toBeNull();
|
|
412
|
+
expect(JSON.stringify(body)).not.toContain("oauth-client-secret-should-not-leak");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("oauth-apps GET includes lastRefreshedAt when tokens are stored", async () => {
|
|
416
|
+
upsertOAuthApp("vendor_oauth", {
|
|
417
|
+
clientId: "vendor-client",
|
|
418
|
+
clientSecret: "oauth-client-secret-should-not-leak",
|
|
419
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
420
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
421
|
+
redirectUri: "https://api.public.test/api/oauth/vendor_oauth/callback",
|
|
422
|
+
scopes: "read,write",
|
|
423
|
+
});
|
|
424
|
+
storeOAuthTokens("vendor_oauth", {
|
|
425
|
+
accessToken: "access-token-should-not-leak",
|
|
426
|
+
refreshToken: "refresh-token-should-not-leak",
|
|
427
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
428
|
+
scope: "read,write",
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const res = await dispatch("/api/oauth-apps");
|
|
432
|
+
expect(res.status).toBe(200);
|
|
433
|
+
const body = (await res.json()) as { oauthApps: Array<Record<string, unknown>> };
|
|
434
|
+
expect(body.oauthApps).toHaveLength(1);
|
|
435
|
+
expect(typeof body.oauthApps[0]?.lastRefreshedAt).toBe("string");
|
|
436
|
+
expect(body.oauthApps[0]?.lastRefreshedAt).toBe(getOAuthTokens("vendor_oauth")?.updatedAt);
|
|
437
|
+
expect(res.text).not.toContain("access-token-should-not-leak");
|
|
438
|
+
expect(res.text).not.toContain("refresh-token-should-not-leak");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("detail returns operations and generated types without secrets", async () => {
|
|
442
|
+
upsertSwarmConfig({
|
|
443
|
+
scope: "global",
|
|
444
|
+
key: "VENDOR_TOKEN",
|
|
445
|
+
value: SECRET_VALUE,
|
|
446
|
+
isSecret: true,
|
|
447
|
+
});
|
|
448
|
+
const binding = upsertCredentialBinding({
|
|
449
|
+
configKey: "VENDOR_TOKEN",
|
|
450
|
+
allowedHosts: ["api.vendor.test"],
|
|
451
|
+
headerTemplate: "Authorization: Bearer [REDACTED:VENDOR_TOKEN]",
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const create = await dispatch("/api/script-connections", {
|
|
455
|
+
method: "POST",
|
|
456
|
+
agentId: leadAgentId,
|
|
457
|
+
body: {
|
|
458
|
+
kind: "openapi",
|
|
459
|
+
slug: "vendor",
|
|
460
|
+
baseUrl: "https://api.vendor.test",
|
|
461
|
+
allowedHosts: ["api.vendor.test"],
|
|
462
|
+
credentialBindingId: binding.id,
|
|
463
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
expect(create.status).toBe(200);
|
|
467
|
+
const created = (await create.json()) as { connection: { id: string } };
|
|
468
|
+
|
|
469
|
+
const res = await dispatch(`/api/script-connections/${created.connection.id}`);
|
|
470
|
+
expect(res.status).toBe(200);
|
|
471
|
+
const body = (await res.json()) as {
|
|
472
|
+
connection: {
|
|
473
|
+
operations: Array<{ name: string; method: string; path: string }>;
|
|
474
|
+
generatedTypes: string;
|
|
475
|
+
specSummary?: { title?: string; version?: string; pathCount: number };
|
|
476
|
+
openapiSpecJson?: string;
|
|
477
|
+
generatedRuntimeJson?: string;
|
|
478
|
+
};
|
|
479
|
+
};
|
|
480
|
+
expect(body.connection.operations).toEqual([
|
|
481
|
+
{
|
|
482
|
+
name: "listItems",
|
|
483
|
+
method: "GET",
|
|
484
|
+
path: "/items",
|
|
485
|
+
parameters: [],
|
|
486
|
+
hasBody: false,
|
|
487
|
+
successStatus: "200",
|
|
488
|
+
responseSchema: {
|
|
489
|
+
type: "object",
|
|
490
|
+
properties: { ok: { type: "boolean" } },
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
]);
|
|
494
|
+
expect(body.connection.generatedTypes).toContain("listItems");
|
|
495
|
+
expect(body.connection.specSummary).toEqual({
|
|
496
|
+
title: "Vendor",
|
|
497
|
+
version: "1.0.0",
|
|
498
|
+
pathCount: 1,
|
|
499
|
+
});
|
|
500
|
+
expect(body.connection.openapiSpecJson).toBeUndefined();
|
|
501
|
+
expect(body.connection.generatedRuntimeJson).toBeUndefined();
|
|
502
|
+
expect(JSON.stringify(body)).not.toContain(SECRET_VALUE);
|
|
503
|
+
expect(JSON.stringify(body)).not.toContain("[REDACTED:VENDOR_TOKEN]");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("DELETE oauth app removes app and tokens", async () => {
|
|
507
|
+
upsertOAuthApp("vendor_oauth", {
|
|
508
|
+
clientId: "vendor-client",
|
|
509
|
+
clientSecret: "oauth-client-secret",
|
|
510
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
511
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
512
|
+
redirectUri: "https://api.public.test/api/oauth/vendor_oauth/callback",
|
|
513
|
+
scopes: "read,write",
|
|
514
|
+
});
|
|
515
|
+
storeOAuthTokens("vendor_oauth", {
|
|
516
|
+
accessToken: "access-token",
|
|
517
|
+
refreshToken: "refresh-token",
|
|
518
|
+
expiresAt: "2035-01-01T00:00:00.000Z",
|
|
519
|
+
scope: "read write",
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth", {
|
|
523
|
+
method: "DELETE",
|
|
524
|
+
agentId: leadAgentId,
|
|
525
|
+
});
|
|
526
|
+
expect(res.status).toBe(200);
|
|
527
|
+
expect(await res.json()).toEqual({ success: true });
|
|
528
|
+
expect(getOAuthApp("vendor_oauth")).toBeNull();
|
|
529
|
+
expect(getOAuthTokens("vendor_oauth")).toBeNull();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("oauth app upsert without clientSecret keeps existing secret", async () => {
|
|
533
|
+
upsertOAuthApp("vendor_oauth", {
|
|
534
|
+
clientId: "vendor-client",
|
|
535
|
+
clientSecret: "existing-client-secret",
|
|
536
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
537
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
538
|
+
redirectUri: "https://api.public.test/api/oauth/vendor_oauth/callback",
|
|
539
|
+
scopes: "read",
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const res = await dispatch("/api/oauth-apps", {
|
|
543
|
+
method: "POST",
|
|
544
|
+
agentId: leadAgentId,
|
|
545
|
+
body: {
|
|
546
|
+
provider: "vendor_oauth",
|
|
547
|
+
clientId: "updated-client",
|
|
548
|
+
authorizeUrl: "https://oauth.vendor.test/oauth2/authorize",
|
|
549
|
+
tokenUrl: "https://oauth.vendor.test/oauth2/token",
|
|
550
|
+
scopes: [],
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
expect(res.status).toBe(200);
|
|
554
|
+
const app = getOAuthApp("vendor_oauth");
|
|
555
|
+
expect(app?.clientId).toBe("updated-client");
|
|
556
|
+
expect(app?.clientSecret).toBe("existing-client-secret");
|
|
557
|
+
expect(app?.scopes).toBe("");
|
|
558
|
+
expect(JSON.stringify(await res.json())).not.toContain("existing-client-secret");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("oauth app upsert rejects reserved tracker providers", async () => {
|
|
562
|
+
const res = await dispatch("/api/oauth-apps", {
|
|
563
|
+
method: "POST",
|
|
564
|
+
agentId: leadAgentId,
|
|
565
|
+
body: {
|
|
566
|
+
provider: "linear",
|
|
567
|
+
clientId: "linear-client",
|
|
568
|
+
clientSecret: "linear-secret",
|
|
569
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
570
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
571
|
+
scopes: [],
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(res.status).toBe(400);
|
|
576
|
+
expect(((await res.json()) as { error: string }).error).toContain("dedicated tracker");
|
|
577
|
+
expect(getOAuthApp("linear")).toBeNull();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("oauth app upsert rejects unsafe endpoint URLs in production and accepts public HTTPS", async () => {
|
|
581
|
+
const previousNodeEnv = process.env.NODE_ENV;
|
|
582
|
+
process.env.NODE_ENV = "production";
|
|
583
|
+
try {
|
|
584
|
+
const rejected = await dispatch("/api/oauth-apps", {
|
|
585
|
+
method: "POST",
|
|
586
|
+
agentId: leadAgentId,
|
|
587
|
+
body: {
|
|
588
|
+
provider: "unsafe_vendor",
|
|
589
|
+
clientId: "unsafe-client",
|
|
590
|
+
clientSecret: "unsafe-secret",
|
|
591
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
592
|
+
tokenUrl: "http://127.0.0.1/token",
|
|
593
|
+
scopes: [],
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
expect(rejected.status).toBe(400);
|
|
597
|
+
expect(((await rejected.json()) as { error: string }).error).toMatch(/private IPv4|insecure/);
|
|
598
|
+
expect(getOAuthApp("unsafe_vendor")).toBeNull();
|
|
599
|
+
|
|
600
|
+
const accepted = await dispatch("/api/oauth-apps", {
|
|
601
|
+
method: "POST",
|
|
602
|
+
agentId: leadAgentId,
|
|
603
|
+
body: {
|
|
604
|
+
provider: "safe_vendor",
|
|
605
|
+
clientId: "safe-client",
|
|
606
|
+
clientSecret: "safe-secret",
|
|
607
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
608
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
609
|
+
scopes: [],
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
expect(accepted.status).toBe(200);
|
|
613
|
+
expect(getOAuthApp("safe_vendor")?.clientId).toBe("safe-client");
|
|
614
|
+
} finally {
|
|
615
|
+
if (previousNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
616
|
+
else process.env.NODE_ENV = previousNodeEnv;
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("discover endpoint parses mocked well-known OAuth JSON", async () => {
|
|
621
|
+
const requested: string[] = [];
|
|
622
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
623
|
+
const url = String(input);
|
|
624
|
+
requested.push(url);
|
|
625
|
+
if (url === "https://issuer.vendor.test/.well-known/oauth-authorization-server") {
|
|
626
|
+
return new Response(
|
|
627
|
+
JSON.stringify({
|
|
628
|
+
authorization_endpoint: "https://issuer.vendor.test/oauth/authorize",
|
|
629
|
+
token_endpoint: "https://issuer.vendor.test/oauth/token",
|
|
630
|
+
scopes_supported: ["read", "write"],
|
|
631
|
+
}),
|
|
632
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
return new Response("not found", { status: 404 });
|
|
636
|
+
}) as typeof fetch;
|
|
637
|
+
|
|
638
|
+
const res = await dispatch("/api/oauth-apps/discover", {
|
|
639
|
+
method: "POST",
|
|
640
|
+
agentId: leadAgentId,
|
|
641
|
+
body: { url: "https://issuer.vendor.test" },
|
|
642
|
+
});
|
|
643
|
+
expect(res.status).toBe(200);
|
|
644
|
+
expect(await res.json()).toEqual({
|
|
645
|
+
authorizeUrl: "https://issuer.vendor.test/oauth/authorize",
|
|
646
|
+
tokenUrl: "https://issuer.vendor.test/oauth/token",
|
|
647
|
+
scopes: ["read", "write"],
|
|
648
|
+
sourceUrl: "https://issuer.vendor.test/.well-known/oauth-authorization-server",
|
|
649
|
+
});
|
|
650
|
+
expect(requested).toEqual([
|
|
651
|
+
"https://issuer.vendor.test/.well-known/oauth-authorization-server",
|
|
652
|
+
]);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("discover endpoint rejects redirects to unsafe hosts in production", async () => {
|
|
656
|
+
const previousNodeEnv = process.env.NODE_ENV;
|
|
657
|
+
process.env.NODE_ENV = "production";
|
|
658
|
+
const requested: string[] = [];
|
|
659
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
660
|
+
const url = String(input);
|
|
661
|
+
requested.push(url);
|
|
662
|
+
return new Response(null, {
|
|
663
|
+
status: 302,
|
|
664
|
+
headers: { location: "http://127.0.0.1/.well-known/oauth-authorization-server" },
|
|
665
|
+
});
|
|
666
|
+
}) as typeof fetch;
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
const res = await dispatch("/api/oauth-apps/discover", {
|
|
670
|
+
method: "POST",
|
|
671
|
+
agentId: leadAgentId,
|
|
672
|
+
body: { url: "https://issuer.vendor.test" },
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
expect(res.status).toBe(400);
|
|
676
|
+
expect(((await res.json()) as { error: string }).error).toMatch(/private IPv4|insecure/);
|
|
677
|
+
expect(requested).toEqual([
|
|
678
|
+
"https://issuer.vendor.test/.well-known/oauth-authorization-server",
|
|
679
|
+
]);
|
|
680
|
+
} finally {
|
|
681
|
+
if (previousNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
682
|
+
else process.env.NODE_ENV = previousNodeEnv;
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("integrations catalog proxy filters cli entries", async () => {
|
|
687
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
688
|
+
expect(String(input)).toBe("https://integrations.sh/api.json");
|
|
689
|
+
return new Response(
|
|
690
|
+
JSON.stringify([
|
|
691
|
+
{
|
|
692
|
+
id: "stripe",
|
|
693
|
+
kind: "openapi",
|
|
694
|
+
slug: "stripe",
|
|
695
|
+
name: "Stripe",
|
|
696
|
+
description: "Payments API",
|
|
697
|
+
url: "https://stripe.com",
|
|
698
|
+
icon: "https://stripe.com/icon.png",
|
|
699
|
+
domain: "stripe.com",
|
|
700
|
+
categories: ["payments"],
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
id: "stripe-cli",
|
|
704
|
+
kind: "cli",
|
|
705
|
+
slug: "stripeCli",
|
|
706
|
+
name: "Stripe CLI",
|
|
707
|
+
},
|
|
708
|
+
]),
|
|
709
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
710
|
+
);
|
|
711
|
+
}) as typeof fetch;
|
|
712
|
+
|
|
713
|
+
const res = await dispatch("/api/integrations-catalog");
|
|
714
|
+
expect(res.status).toBe(200);
|
|
715
|
+
const body = (await res.json()) as {
|
|
716
|
+
entries: Array<{ id: string; kind: string; slug: string; name: string }>;
|
|
717
|
+
};
|
|
718
|
+
expect(body.entries).toEqual([
|
|
719
|
+
{
|
|
720
|
+
id: "stripe",
|
|
721
|
+
kind: "openapi",
|
|
722
|
+
slug: "stripe",
|
|
723
|
+
name: "Stripe",
|
|
724
|
+
description: "Payments API",
|
|
725
|
+
url: "https://stripe.com",
|
|
726
|
+
icon: "https://stripe.com/icon.png",
|
|
727
|
+
domain: "stripe.com",
|
|
728
|
+
categories: ["payments"],
|
|
729
|
+
feeds: [],
|
|
730
|
+
},
|
|
731
|
+
]);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Contract: the surface proxy passes through a TRIMMED shape only —
|
|
735
|
+
// {domain, summary, surfaces: [{type, name, url, docs, spec, auth: {required,
|
|
736
|
+
// credentialIds, mechanics}}], credentials: {id: {type, label, generateUrl,
|
|
737
|
+
// setup}}}. CLI surfaces are filtered out (connections are http/mcp only)
|
|
738
|
+
// and credentials are narrowed to ids referenced by retained surfaces.
|
|
739
|
+
test("integrations surface proxy trims payload and filters cli surfaces", async () => {
|
|
740
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
741
|
+
expect(String(input)).toBe("https://integrations.sh/api/stripe.com/surface");
|
|
742
|
+
return new Response(
|
|
743
|
+
JSON.stringify({
|
|
744
|
+
version: 3,
|
|
745
|
+
domain: "stripe.com",
|
|
746
|
+
detect: { probed: ["llms.txt"] },
|
|
747
|
+
summary: "Stripe exposes a REST HTTP API and an MCP server.",
|
|
748
|
+
discoveredAt: "2026-07-08T00:00:00.000Z",
|
|
749
|
+
usedLlm: true,
|
|
750
|
+
surfaces: [
|
|
751
|
+
{
|
|
752
|
+
type: "http",
|
|
753
|
+
name: "Stripe API",
|
|
754
|
+
slug: "stripe-api",
|
|
755
|
+
url: "https://api.stripe.com",
|
|
756
|
+
docs: "https://docs.stripe.com/api",
|
|
757
|
+
spec: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml",
|
|
758
|
+
basis: { via: "discovered", evidence: ["https://docs.stripe.com/api.md"] },
|
|
759
|
+
auth: {
|
|
760
|
+
status: "required",
|
|
761
|
+
entries: [
|
|
762
|
+
{
|
|
763
|
+
use: [
|
|
764
|
+
{
|
|
765
|
+
id: "stripe_api_key",
|
|
766
|
+
mechanics: {
|
|
767
|
+
source: "http",
|
|
768
|
+
in: "header",
|
|
769
|
+
headerName: "Authorization",
|
|
770
|
+
scheme: "Bearer",
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
],
|
|
774
|
+
basis: { via: "discovered" },
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
type: "mcp",
|
|
781
|
+
name: "Stripe MCP server",
|
|
782
|
+
slug: "stripe-mcp-server",
|
|
783
|
+
url: "https://mcp.stripe.com",
|
|
784
|
+
docs: "https://docs.stripe.com/mcp",
|
|
785
|
+
auth: {
|
|
786
|
+
status: "required",
|
|
787
|
+
entries: [
|
|
788
|
+
{ use: [{ id: "stripe_mcp_oauth", mechanics: { source: "well-known" } }] },
|
|
789
|
+
],
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
type: "cli",
|
|
794
|
+
name: "Stripe CLI",
|
|
795
|
+
docs: "https://docs.stripe.com/stripe-cli",
|
|
796
|
+
auth: {
|
|
797
|
+
status: "required",
|
|
798
|
+
entries: [
|
|
799
|
+
{
|
|
800
|
+
use: [
|
|
801
|
+
{
|
|
802
|
+
id: "stripe_cli_session",
|
|
803
|
+
mechanics: { source: "cli", command: "stripe login" },
|
|
804
|
+
},
|
|
805
|
+
],
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
credentials: {
|
|
812
|
+
stripe_api_key: {
|
|
813
|
+
type: "api_key",
|
|
814
|
+
label: "Stripe API key",
|
|
815
|
+
generateUrl: "https://dashboard.stripe.com/test/apikeys",
|
|
816
|
+
setup: "Create or reveal a key in the API keys page.",
|
|
817
|
+
acquisition: "manual",
|
|
818
|
+
},
|
|
819
|
+
stripe_mcp_oauth: {
|
|
820
|
+
type: "oauth2",
|
|
821
|
+
label: "Stripe MCP OAuth authorization",
|
|
822
|
+
setup: "Use an MCP client that supports OAuth.",
|
|
823
|
+
acquisition: "manual",
|
|
824
|
+
},
|
|
825
|
+
stripe_cli_session: {
|
|
826
|
+
type: "compound",
|
|
827
|
+
label: "Stripe CLI session",
|
|
828
|
+
setup: "Install the CLI, then run stripe login.",
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
}),
|
|
832
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
833
|
+
);
|
|
834
|
+
}) as typeof fetch;
|
|
835
|
+
|
|
836
|
+
const res = await dispatch("/api/integrations-catalog/stripe.com/surface");
|
|
837
|
+
expect(res.status).toBe(200);
|
|
838
|
+
expect(await res.json()).toEqual({
|
|
839
|
+
domain: "stripe.com",
|
|
840
|
+
summary: "Stripe exposes a REST HTTP API and an MCP server.",
|
|
841
|
+
surfaces: [
|
|
842
|
+
{
|
|
843
|
+
type: "http",
|
|
844
|
+
name: "Stripe API",
|
|
845
|
+
url: "https://api.stripe.com",
|
|
846
|
+
docs: "https://docs.stripe.com/api",
|
|
847
|
+
spec: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.yaml",
|
|
848
|
+
auth: {
|
|
849
|
+
required: true,
|
|
850
|
+
credentialIds: ["stripe_api_key"],
|
|
851
|
+
mechanics: { in: "header", headerName: "Authorization", scheme: "Bearer" },
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
type: "mcp",
|
|
856
|
+
name: "Stripe MCP server",
|
|
857
|
+
url: "https://mcp.stripe.com",
|
|
858
|
+
docs: "https://docs.stripe.com/mcp",
|
|
859
|
+
spec: null,
|
|
860
|
+
auth: { required: true, credentialIds: ["stripe_mcp_oauth"], mechanics: null },
|
|
861
|
+
},
|
|
862
|
+
],
|
|
863
|
+
credentials: {
|
|
864
|
+
stripe_api_key: {
|
|
865
|
+
type: "api_key",
|
|
866
|
+
label: "Stripe API key",
|
|
867
|
+
generateUrl: "https://dashboard.stripe.com/test/apikeys",
|
|
868
|
+
setup: "Create or reveal a key in the API keys page.",
|
|
869
|
+
},
|
|
870
|
+
stripe_mcp_oauth: {
|
|
871
|
+
type: "oauth2",
|
|
872
|
+
label: "Stripe MCP OAuth authorization",
|
|
873
|
+
generateUrl: null,
|
|
874
|
+
setup: "Use an MCP client that supports OAuth.",
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
test("integrations surface proxy caches by domain", async () => {
|
|
881
|
+
let calls = 0;
|
|
882
|
+
globalThis.fetch = (async () => {
|
|
883
|
+
calls += 1;
|
|
884
|
+
return new Response(
|
|
885
|
+
JSON.stringify({ domain: "cached.example", summary: "hi", surfaces: [], credentials: {} }),
|
|
886
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
887
|
+
);
|
|
888
|
+
}) as typeof fetch;
|
|
889
|
+
|
|
890
|
+
expect((await dispatch("/api/integrations-catalog/cached.example/surface")).status).toBe(200);
|
|
891
|
+
expect((await dispatch("/api/integrations-catalog/CACHED.example/surface")).status).toBe(200);
|
|
892
|
+
expect(calls).toBe(1);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test("integrations surface proxy passes through upstream 404", async () => {
|
|
896
|
+
globalThis.fetch = (async () =>
|
|
897
|
+
new Response(JSON.stringify({ error: "surface not found" }), {
|
|
898
|
+
status: 404,
|
|
899
|
+
})) as unknown as typeof fetch;
|
|
900
|
+
|
|
901
|
+
const res = await dispatch("/api/integrations-catalog/unknown-404.example/surface");
|
|
902
|
+
expect(res.status).toBe(404);
|
|
903
|
+
expect(res.text).toContain("No integration surface found");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
test("integrations surface proxy maps other upstream failures to 502", async () => {
|
|
907
|
+
globalThis.fetch = (async () =>
|
|
908
|
+
new Response("boom", { status: 500 })) as unknown as typeof fetch;
|
|
909
|
+
|
|
910
|
+
const res = await dispatch("/api/integrations-catalog/broken-500.example/surface");
|
|
911
|
+
expect(res.status).toBe(502);
|
|
912
|
+
expect(res.text).toContain("Failed to fetch integration surface");
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("integrations surface proxy rejects invalid domains", async () => {
|
|
916
|
+
let called = false;
|
|
917
|
+
globalThis.fetch = (async () => {
|
|
918
|
+
called = true;
|
|
919
|
+
return new Response("{}", { status: 200 });
|
|
920
|
+
}) as unknown as typeof fetch;
|
|
921
|
+
|
|
922
|
+
const res = await dispatch("/api/integrations-catalog/bad_domain/surface");
|
|
923
|
+
expect(res.status).toBe(400);
|
|
924
|
+
expect(called).toBe(false);
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
describe("DELETE /api/oauth-apps/{provider}/tokens", () => {
|
|
929
|
+
const ACCESS_TOKEN = "access-token-should-not-leak";
|
|
930
|
+
const realFetch = globalThis.fetch;
|
|
931
|
+
|
|
932
|
+
afterEach(() => {
|
|
933
|
+
globalThis.fetch = realFetch;
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
function seedOAuthApp(metadata?: Record<string, unknown>) {
|
|
937
|
+
upsertOAuthApp("vendor_oauth", {
|
|
938
|
+
clientId: "vendor-client",
|
|
939
|
+
clientSecret: "oauth-client-secret-should-not-leak",
|
|
940
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
941
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
942
|
+
redirectUri: "https://api.public.test/api/oauth/vendor_oauth/callback",
|
|
943
|
+
scopes: "read,write",
|
|
944
|
+
...(metadata ? { metadata: JSON.stringify(metadata) } : {}),
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function seedTokens() {
|
|
949
|
+
storeOAuthTokens("vendor_oauth", {
|
|
950
|
+
accessToken: ACCESS_TOKEN,
|
|
951
|
+
refreshToken: "refresh-token-should-not-leak",
|
|
952
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
953
|
+
scope: "read,write",
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
test("404 for unknown provider", async () => {
|
|
958
|
+
const res = await dispatch("/api/oauth-apps/unknown_provider/tokens", {
|
|
959
|
+
method: "DELETE",
|
|
960
|
+
agentId: leadAgentId,
|
|
961
|
+
});
|
|
962
|
+
expect(res.status).toBe(404);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
test("returns disconnected:false when no stored tokens", async () => {
|
|
966
|
+
seedOAuthApp();
|
|
967
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/tokens", {
|
|
968
|
+
method: "DELETE",
|
|
969
|
+
agentId: leadAgentId,
|
|
970
|
+
});
|
|
971
|
+
expect(res.status).toBe(200);
|
|
972
|
+
expect(await res.json()).toEqual({ disconnected: false, message: "no stored tokens" });
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("deletes the oauth_tokens row and returns disconnected:true", async () => {
|
|
976
|
+
seedOAuthApp();
|
|
977
|
+
seedTokens();
|
|
978
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/tokens", {
|
|
979
|
+
method: "DELETE",
|
|
980
|
+
agentId: leadAgentId,
|
|
981
|
+
});
|
|
982
|
+
expect(res.status).toBe(200);
|
|
983
|
+
expect(await res.json()).toEqual({ disconnected: true, revocationAttempted: false });
|
|
984
|
+
expect(getOAuthTokens("vendor_oauth")).toBeNull();
|
|
985
|
+
expect(res.text).not.toContain(ACCESS_TOKEN);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test("attempts remote revocation when metadata.revocationUrl is set", async () => {
|
|
989
|
+
seedOAuthApp({ revocationUrl: "https://oauth.vendor.test/revoke" });
|
|
990
|
+
seedTokens();
|
|
991
|
+
|
|
992
|
+
let captured: { url: string; method?: string; body?: string } | null = null;
|
|
993
|
+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
994
|
+
captured = {
|
|
995
|
+
url: String(input),
|
|
996
|
+
method: init?.method,
|
|
997
|
+
body: typeof init?.body === "string" ? init.body : undefined,
|
|
998
|
+
};
|
|
999
|
+
return new Response("", { status: 200 });
|
|
1000
|
+
}) as typeof fetch;
|
|
1001
|
+
|
|
1002
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/tokens", {
|
|
1003
|
+
method: "DELETE",
|
|
1004
|
+
agentId: leadAgentId,
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
expect(res.status).toBe(200);
|
|
1008
|
+
expect(await res.json()).toEqual({ disconnected: true, revocationAttempted: true });
|
|
1009
|
+
expect(getOAuthTokens("vendor_oauth")).toBeNull();
|
|
1010
|
+
|
|
1011
|
+
expect(captured).not.toBeNull();
|
|
1012
|
+
expect(captured?.url).toBe("https://oauth.vendor.test/revoke");
|
|
1013
|
+
expect(captured?.method).toBe("POST");
|
|
1014
|
+
expect(captured?.body).toContain("token_type_hint=access_token");
|
|
1015
|
+
expect(captured?.body).toContain(`token=${ACCESS_TOKEN}`);
|
|
1016
|
+
// Tokens and secrets must never leak into the HTTP response.
|
|
1017
|
+
expect(res.text).not.toContain(ACCESS_TOKEN);
|
|
1018
|
+
expect(res.text).not.toContain("oauth-client-secret-should-not-leak");
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
describe("POST /api/oauth-apps/{provider}/refresh", () => {
|
|
1023
|
+
const ACCESS_TOKEN = "access-token-should-not-leak";
|
|
1024
|
+
const REFRESH_TOKEN = "refresh-token-should-not-leak";
|
|
1025
|
+
const realFetch = globalThis.fetch;
|
|
1026
|
+
|
|
1027
|
+
afterEach(() => {
|
|
1028
|
+
globalThis.fetch = realFetch;
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
function seedOAuthApp() {
|
|
1032
|
+
upsertOAuthApp("vendor_oauth", {
|
|
1033
|
+
clientId: "vendor-client",
|
|
1034
|
+
clientSecret: "oauth-client-secret-should-not-leak",
|
|
1035
|
+
authorizeUrl: "https://oauth.vendor.test/authorize",
|
|
1036
|
+
tokenUrl: "https://oauth.vendor.test/token",
|
|
1037
|
+
redirectUri: "https://api.public.test/api/oauth/vendor_oauth/callback",
|
|
1038
|
+
scopes: "read,write",
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function seedTokens(refreshToken: string | null) {
|
|
1043
|
+
storeOAuthTokens("vendor_oauth", {
|
|
1044
|
+
accessToken: ACCESS_TOKEN,
|
|
1045
|
+
refreshToken,
|
|
1046
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
1047
|
+
scope: "read,write",
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
test("404 for unknown provider", async () => {
|
|
1052
|
+
const res = await dispatch("/api/oauth-apps/unknown_provider/refresh", {
|
|
1053
|
+
method: "POST",
|
|
1054
|
+
agentId: leadAgentId,
|
|
1055
|
+
});
|
|
1056
|
+
expect(res.status).toBe(404);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("400 when no tokens are stored", async () => {
|
|
1060
|
+
seedOAuthApp();
|
|
1061
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/refresh", {
|
|
1062
|
+
method: "POST",
|
|
1063
|
+
agentId: leadAgentId,
|
|
1064
|
+
});
|
|
1065
|
+
expect(res.status).toBe(400);
|
|
1066
|
+
expect(await res.json()).toEqual({ error: "Nothing to refresh — authorize first." });
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
test("400 when no refresh token is stored", async () => {
|
|
1070
|
+
seedOAuthApp();
|
|
1071
|
+
seedTokens(null);
|
|
1072
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/refresh", {
|
|
1073
|
+
method: "POST",
|
|
1074
|
+
agentId: leadAgentId,
|
|
1075
|
+
});
|
|
1076
|
+
expect(res.status).toBe(400);
|
|
1077
|
+
expect(res.text).toContain("does not support refresh");
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("forces a refresh regardless of expiry and never leaks token values", async () => {
|
|
1081
|
+
seedOAuthApp();
|
|
1082
|
+
seedTokens(REFRESH_TOKEN); // token still valid for an hour — refresh is forced anyway
|
|
1083
|
+
|
|
1084
|
+
let captured: { url: string; body?: string } | null = null;
|
|
1085
|
+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
1086
|
+
captured = {
|
|
1087
|
+
url: String(input),
|
|
1088
|
+
body: typeof init?.body === "string" ? init.body : undefined,
|
|
1089
|
+
};
|
|
1090
|
+
return new Response(
|
|
1091
|
+
JSON.stringify({
|
|
1092
|
+
access_token: "new-access-token-should-not-leak",
|
|
1093
|
+
token_type: "bearer",
|
|
1094
|
+
expires_in: 7200,
|
|
1095
|
+
refresh_token: "new-refresh-token-should-not-leak",
|
|
1096
|
+
scope: "read,write",
|
|
1097
|
+
}),
|
|
1098
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
1099
|
+
);
|
|
1100
|
+
}) as typeof fetch;
|
|
1101
|
+
|
|
1102
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/refresh", {
|
|
1103
|
+
method: "POST",
|
|
1104
|
+
agentId: leadAgentId,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
expect(res.status).toBe(200);
|
|
1108
|
+
const body = (await res.json()) as {
|
|
1109
|
+
refreshed: boolean;
|
|
1110
|
+
tokenStatus: string;
|
|
1111
|
+
expiresAt: string | null;
|
|
1112
|
+
};
|
|
1113
|
+
expect(body.refreshed).toBe(true);
|
|
1114
|
+
expect(body.tokenStatus).toBe("ok");
|
|
1115
|
+
|
|
1116
|
+
// The token endpoint was hit with a refresh_token grant.
|
|
1117
|
+
expect(captured).not.toBeNull();
|
|
1118
|
+
expect(captured?.url).toBe("https://oauth.vendor.test/token");
|
|
1119
|
+
expect(captured?.body).toContain("grant_type=refresh_token");
|
|
1120
|
+
|
|
1121
|
+
// Response carries the NEW expiry from the mocked expires_in=7200.
|
|
1122
|
+
const stored = getOAuthTokens("vendor_oauth");
|
|
1123
|
+
expect(stored?.accessToken).toBe("new-access-token-should-not-leak");
|
|
1124
|
+
expect(body.expiresAt).toBe(stored?.expiresAt ?? "");
|
|
1125
|
+
expect(new Date(body.expiresAt ?? 0).getTime()).toBeGreaterThan(Date.now() + 3_600_000);
|
|
1126
|
+
|
|
1127
|
+
// No token values in the HTTP response.
|
|
1128
|
+
expect(res.text).not.toContain(ACCESS_TOKEN);
|
|
1129
|
+
expect(res.text).not.toContain(REFRESH_TOKEN);
|
|
1130
|
+
expect(res.text).not.toContain("new-access-token-should-not-leak");
|
|
1131
|
+
expect(res.text).not.toContain("new-refresh-token-should-not-leak");
|
|
1132
|
+
expect(res.text).not.toContain("oauth-client-secret-should-not-leak");
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
test("502 when the provider token endpoint rejects the refresh", async () => {
|
|
1136
|
+
seedOAuthApp();
|
|
1137
|
+
seedTokens(REFRESH_TOKEN);
|
|
1138
|
+
globalThis.fetch = (async () =>
|
|
1139
|
+
new Response("nope", { status: 400 })) as unknown as typeof fetch;
|
|
1140
|
+
|
|
1141
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/refresh", {
|
|
1142
|
+
method: "POST",
|
|
1143
|
+
agentId: leadAgentId,
|
|
1144
|
+
});
|
|
1145
|
+
expect(res.status).toBe(502);
|
|
1146
|
+
expect(res.text).toContain("Token refresh failed");
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("403 for non-lead agent", async () => {
|
|
1150
|
+
seedOAuthApp();
|
|
1151
|
+
seedTokens(REFRESH_TOKEN);
|
|
1152
|
+
const res = await dispatch("/api/oauth-apps/vendor_oauth/refresh", {
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
agentId: workerAgentId,
|
|
1155
|
+
});
|
|
1156
|
+
expect(res.status).toBe(403);
|
|
1157
|
+
});
|
|
1158
|
+
});
|