215 lines
7.8 KiB
TypeScript
215 lines
7.8 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { createHash } from "node:crypto"
|
|
import test from "node:test"
|
|
import { z } from "zod"
|
|
import {
|
|
createDefaultScenario,
|
|
createEnterpriseMcpMockServer,
|
|
getProviderProfile,
|
|
probeEnterpriseMcpMockServer,
|
|
type EnterpriseMcpMockServer,
|
|
type EnterpriseMcpScenario,
|
|
} from "../src/index.js"
|
|
import { authorizeManual, initializeSession, rpcHeaders } from "./wire-client.js"
|
|
|
|
const oauthClientSecret = "slack-user-mcp-client-secret-32-bytes"
|
|
const verifier = "slack-wire-pkce-verifier-with-more-than-43-characters-1234567890"
|
|
|
|
async function authorizationCode(
|
|
server: EnterpriseMcpMockServer,
|
|
scenario: EnterpriseMcpScenario,
|
|
): Promise<string> {
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const redirectUri = scenario.oauth.redirectUris[0]
|
|
assert.ok(redirectUri)
|
|
const authorize = new URL(profile.oauth.authorizationPath, server.baseUrl)
|
|
authorize.searchParams.set("response_type", "code")
|
|
authorize.searchParams.set("client_id", scenario.oauth.clientId)
|
|
authorize.searchParams.set("redirect_uri", redirectUri)
|
|
authorize.searchParams.set("scope", scenario.oauth.authorizationScopes.join(" "))
|
|
authorize.searchParams.set("resource", server.mcpUrl)
|
|
authorize.searchParams.set("state", "slack-wire-state")
|
|
authorize.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url"))
|
|
authorize.searchParams.set("code_challenge_method", "S256")
|
|
const response = await fetch(authorize, { redirect: "manual" })
|
|
assert.equal(response.status, 302)
|
|
const location = response.headers.get("location")
|
|
assert.ok(location)
|
|
const code = new URL(location).searchParams.get("code")
|
|
assert.ok(code)
|
|
return code
|
|
}
|
|
|
|
function tokenForm(
|
|
server: EnterpriseMcpMockServer,
|
|
scenario: EnterpriseMcpScenario,
|
|
code: string,
|
|
): URLSearchParams {
|
|
const redirectUri = scenario.oauth.redirectUris[0]
|
|
assert.ok(redirectUri)
|
|
return new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: oauthClientSecret,
|
|
redirect_uri: redirectUri,
|
|
resource: server.mcpUrl,
|
|
code,
|
|
code_verifier: verifier,
|
|
})
|
|
}
|
|
|
|
test("Slack profile returns user-token success bodies with observed expiry and token prefixes", async () => {
|
|
const scenario = createDefaultScenario("slack-user-mcp")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const response = await fetch(new URL(profile.oauth.tokenPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: tokenForm(server, scenario, await authorizationCode(server, scenario)),
|
|
})
|
|
assert.equal(response.status, 200)
|
|
const value: unknown = JSON.parse(await response.text())
|
|
const token = z.object({
|
|
ok: z.literal(true),
|
|
access_token: z.string(),
|
|
refresh_token: z.string(),
|
|
token_type: z.literal("user"),
|
|
expires_in: z.literal(43_200),
|
|
scope: z.string(),
|
|
}).strict().parse(value)
|
|
assert.match(token.access_token, /^xoxp-/)
|
|
assert.match(token.refresh_token, /^xoxe-1-/)
|
|
assert.equal(token.scope, scenario.oauth.authorizationScopes.join(" "))
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("Slack profile returns HTTP 200 ok:false for bad code, refresh token, and client secret", async () => {
|
|
const scenario = createDefaultScenario("slack-user-mcp")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const tokenUrl = new URL(profile.oauth.tokenPath, server.baseUrl)
|
|
const badCode = await fetch(tokenUrl, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: tokenForm(server, scenario, "not-a-real-code"),
|
|
})
|
|
assert.equal(badCode.status, 200)
|
|
assert.deepEqual(JSON.parse(await badCode.text()), { ok: false, error: "invalid_code" })
|
|
|
|
const badRefresh = await fetch(tokenUrl, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: oauthClientSecret,
|
|
refresh_token: "not-a-real-refresh-token",
|
|
}),
|
|
})
|
|
assert.equal(badRefresh.status, 200)
|
|
assert.deepEqual(JSON.parse(await badRefresh.text()), { ok: false, error: "invalid_refresh_token" })
|
|
|
|
const badClient = await fetch(tokenUrl, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: "wrong-slack-client-secret",
|
|
code: "not-a-real-code",
|
|
}),
|
|
})
|
|
assert.equal(badClient.status, 200)
|
|
assert.deepEqual(JSON.parse(await badClient.text()), { ok: false, error: "bad_client_secret" })
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("Slack profile strictly rejects unsupported initialize versions after authentication", async () => {
|
|
const scenario = createDefaultScenario("slack-user-mcp")
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const token = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const unsupported = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, token.accessToken),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
clientInfo: { name: "slack-wire-test", version: "1" },
|
|
},
|
|
}),
|
|
})
|
|
assert.equal(unsupported.status, 400)
|
|
assert.deepEqual(JSON.parse(await unsupported.text()), {
|
|
error: "unsupported_protocol_version",
|
|
supportedVersions: ["2025-06-18"],
|
|
})
|
|
|
|
const supported = await initializeSession(server.baseUrl, scenario, token)
|
|
assert.equal(supported.protocolVersion, "2025-06-18")
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("Slack profile checks authentication before strict initialize version validation", async () => {
|
|
const scenario = createDefaultScenario("slack-user-mcp")
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const response = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
accept: "application/json, text/event-stream",
|
|
"content-type": "application/json",
|
|
origin: new URL(server.baseUrl).origin,
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: "2025-11-25",
|
|
capabilities: {},
|
|
clientInfo: { name: "slack-wire-test", version: "1" },
|
|
},
|
|
}),
|
|
})
|
|
assert.equal(response.status, 401)
|
|
assert.equal(response.headers.has("www-authenticate"), true)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("default probe accepts Slack user tokens only for the Slack response profile", async () => {
|
|
const scenario = createDefaultScenario("slack-user-mcp")
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const result = await probeEnterpriseMcpMockServer({
|
|
baseUrl: server.baseUrl,
|
|
scenario,
|
|
credentials: { clientSecret: oauthClientSecret },
|
|
})
|
|
assert.equal(result.ok, true, result.error?.messageSafe)
|
|
assert.equal(result.negotiatedProtocolVersion, "2025-06-18")
|
|
assert.equal(result.toolCount, 3)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|