feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
This commit is contained in:
259
agent/extensions/pi-mcp-adapter/mcp-auth-flow.test.ts
Normal file
259
agent/extensions/pi-mcp-adapter/mcp-auth-flow.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Tests for mcp-auth-flow.ts - OAuth flow using MCP SDK
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test"
|
||||
import assert from "node:assert"
|
||||
import { existsSync, rmSync, mkdirSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
// Set up isolated temp directory for tests
|
||||
const TEST_DIR = join(tmpdir(), `mcp-oauth-test-${randomBytes(4).toString('hex')}`)
|
||||
process.env.MCP_OAUTH_DIR = TEST_DIR
|
||||
|
||||
import {
|
||||
authenticate,
|
||||
startAuth,
|
||||
completeAuth,
|
||||
getAuthStatus,
|
||||
removeAuth,
|
||||
supportsOAuth,
|
||||
extractOAuthConfig,
|
||||
initializeOAuth,
|
||||
shutdownOAuth,
|
||||
type AuthStatus,
|
||||
} from "./mcp-auth-flow.ts"
|
||||
import { isCallbackServerRunning } from "./mcp-callback-server.ts"
|
||||
import { updateTokens, clearAllCredentials } from "./mcp-auth.ts"
|
||||
import type { ServerEntry } from "./types.ts"
|
||||
|
||||
describe("mcp-auth-flow", () => {
|
||||
before(() => {
|
||||
// Ensure clean state
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// Shutdown OAuth and clean up
|
||||
await shutdownOAuth()
|
||||
try {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("supportsOAuth", () => {
|
||||
it("should return true for OAuth HTTP server", () => {
|
||||
const definition: ServerEntry = {
|
||||
url: "https://api.example.com/mcp",
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), true)
|
||||
})
|
||||
|
||||
it("should return false for bearer auth", () => {
|
||||
const definition: ServerEntry = {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "bearer",
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
|
||||
it("should return false for stdio server", () => {
|
||||
const definition: ServerEntry = {
|
||||
command: "npx",
|
||||
args: ["-y", "@example/mcp-server"],
|
||||
}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
|
||||
it("should return false when no URL", () => {
|
||||
const definition: ServerEntry = {}
|
||||
assert.strictEqual(supportsOAuth(definition), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAuthStatus", () => {
|
||||
it("should return 'not_authenticated' when no tokens", async () => {
|
||||
const status = await getAuthStatus("status-test-none")
|
||||
assert.strictEqual(status, "not_authenticated")
|
||||
})
|
||||
|
||||
it("should return 'authenticated' when tokens exist and not expired", async () => {
|
||||
await updateTokens("status-test-ok", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 + 3600, // 1 hour from now
|
||||
})
|
||||
|
||||
const status = await getAuthStatus("status-test-ok")
|
||||
assert.strictEqual(status, "authenticated")
|
||||
})
|
||||
|
||||
it("should return 'expired' when tokens are expired", async () => {
|
||||
await updateTokens("status-test-expired", {
|
||||
accessToken: "token",
|
||||
expiresAt: Date.now() / 1000 - 3600, // 1 hour ago
|
||||
})
|
||||
|
||||
const status = await getAuthStatus("status-test-expired")
|
||||
assert.strictEqual(status, "expired")
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeAuth", () => {
|
||||
it("should remove all credentials", async () => {
|
||||
await updateTokens("remove-test", { accessToken: "token" })
|
||||
|
||||
await removeAuth("remove-test")
|
||||
|
||||
const status = await getAuthStatus("remove-test")
|
||||
assert.strictEqual(status, "not_authenticated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("initializeOAuth / shutdownOAuth", () => {
|
||||
it("should not start callback server on initialize", async () => {
|
||||
await shutdownOAuth()
|
||||
await initializeOAuth()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
|
||||
it("should stop callback server on shutdown", async () => {
|
||||
await initializeOAuth()
|
||||
await shutdownOAuth()
|
||||
assert.strictEqual(isCallbackServerRunning(), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authenticate / completeAuth", () => {
|
||||
it("should throw if no server URL provided", async () => {
|
||||
await assert.rejects(
|
||||
async () => await authenticate("no-url-test", ""),
|
||||
/Invalid URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject malformed OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("bad-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "not a url" },
|
||||
}),
|
||||
/Invalid OAuth redirectUri/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-local OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("remote-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "https://example.com:3118/callback" },
|
||||
}),
|
||||
/localhost or loopback/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values without an explicit port", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("no-port-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://localhost/callback" },
|
||||
}),
|
||||
/explicit numeric port/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject blank OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("blank-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: " " },
|
||||
}),
|
||||
/redirectUri must not be empty/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-string OAuth redirectUri values", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("typed-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: 3118 as unknown as string },
|
||||
}),
|
||||
/redirectUri must be a string/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values with fragments", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("fragment-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://localhost:3118/callback#fragment" },
|
||||
}),
|
||||
/redirectUri must not include a fragment/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject OAuth redirectUri values with username or password", async () => {
|
||||
await assert.rejects(
|
||||
async () => await startAuth("credential-redirect", "https://api.example.com/mcp", {
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { redirectUri: "http://user:pass@localhost:3118/callback" },
|
||||
}),
|
||||
/redirectUri must not include username or password/
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject non-string OAuth clientName and clientUri values", () => {
|
||||
assert.throws(
|
||||
() => extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { clientName: 123 as unknown as string },
|
||||
}),
|
||||
/clientName must be a string/
|
||||
)
|
||||
assert.throws(
|
||||
() => extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: { clientUri: 123 as unknown as string },
|
||||
}),
|
||||
/clientUri must be a string/
|
||||
)
|
||||
})
|
||||
|
||||
it("should trim OAuth redirectUri and client metadata values", () => {
|
||||
const config = extractOAuthConfig({
|
||||
url: "https://api.example.com/mcp",
|
||||
auth: "oauth",
|
||||
oauth: {
|
||||
redirectUri: " http://localhost:3118/callback ",
|
||||
clientName: " Custom MCP ",
|
||||
clientUri: " https://example.com/custom ",
|
||||
},
|
||||
})
|
||||
|
||||
assert.strictEqual(config.redirectUri, "http://localhost:3118/callback")
|
||||
assert.strictEqual(config.clientName, "Custom MCP")
|
||||
assert.strictEqual(config.clientUri, "https://example.com/custom")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user