|
@@ -0,0 +1,261 @@
|
|
|
|
|
+import { test, expect, mock, beforeEach } from "bun:test"
|
|
|
|
|
+import { EventEmitter } from "events"
|
|
|
|
|
+
|
|
|
|
|
+// Track open() calls and control failure behavior
|
|
|
|
|
+let openShouldFail = false
|
|
|
|
|
+let openCalledWith: string | undefined
|
|
|
|
|
+
|
|
|
|
|
+mock.module("open", () => ({
|
|
|
|
|
+ default: async (url: string) => {
|
|
|
|
|
+ openCalledWith = url
|
|
|
|
|
+ // Return a mock subprocess that emits an error if openShouldFail is true
|
|
|
|
|
+ const subprocess = new EventEmitter()
|
|
|
|
|
+ if (openShouldFail) {
|
|
|
|
|
+ // Emit error asynchronously like a real subprocess would
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ subprocess.emit("error", new Error("spawn xdg-open ENOENT"))
|
|
|
|
|
+ }, 10)
|
|
|
|
|
+ }
|
|
|
|
|
+ return subprocess
|
|
|
|
|
+ },
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
|
|
+// Mock UnauthorizedError
|
|
|
|
|
+class MockUnauthorizedError extends Error {
|
|
|
|
|
+ constructor() {
|
|
|
|
|
+ super("Unauthorized")
|
|
|
|
|
+ this.name = "UnauthorizedError"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Track what options were passed to each transport constructor
|
|
|
|
|
+const transportCalls: Array<{
|
|
|
|
|
+ type: "streamable" | "sse"
|
|
|
|
|
+ url: string
|
|
|
|
|
+ options: { authProvider?: unknown }
|
|
|
|
|
+}> = []
|
|
|
|
|
+
|
|
|
|
|
+// Mock the transport constructors
|
|
|
|
|
+mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
|
|
|
|
+ StreamableHTTPClientTransport: class MockStreamableHTTP {
|
|
|
|
|
+ url: string
|
|
|
|
|
+ authProvider: { redirectToAuthorization?: (url: URL) => Promise<void> } | undefined
|
|
|
|
|
+ constructor(url: URL, options?: { authProvider?: { redirectToAuthorization?: (url: URL) => Promise<void> } }) {
|
|
|
|
|
+ this.url = url.toString()
|
|
|
|
|
+ this.authProvider = options?.authProvider
|
|
|
|
|
+ transportCalls.push({
|
|
|
|
|
+ type: "streamable",
|
|
|
|
|
+ url: url.toString(),
|
|
|
|
|
+ options: options ?? {},
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ async start() {
|
|
|
|
|
+ // Simulate OAuth redirect by calling the authProvider's redirectToAuthorization
|
|
|
|
|
+ if (this.authProvider?.redirectToAuthorization) {
|
|
|
|
|
+ await this.authProvider.redirectToAuthorization(new URL("https://auth.example.com/authorize?client_id=test"))
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new MockUnauthorizedError()
|
|
|
|
|
+ }
|
|
|
|
|
+ async finishAuth(_code: string) {
|
|
|
|
|
+ // Mock successful auth completion
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
|
|
+mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
|
|
|
|
+ SSEClientTransport: class MockSSE {
|
|
|
|
|
+ constructor(url: URL) {
|
|
|
|
|
+ transportCalls.push({
|
|
|
|
|
+ type: "sse",
|
|
|
|
|
+ url: url.toString(),
|
|
|
|
|
+ options: {},
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ async start() {
|
|
|
|
|
+ throw new Error("Mock SSE transport cannot connect")
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
|
|
+// Mock the MCP SDK Client to trigger OAuth flow
|
|
|
|
|
+mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
|
|
|
|
|
+ Client: class MockClient {
|
|
|
|
|
+ async connect(transport: { start: () => Promise<void> }) {
|
|
|
|
|
+ await transport.start()
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
|
|
+// Mock UnauthorizedError in the auth module
|
|
|
|
|
+mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|
|
|
|
+ UnauthorizedError: MockUnauthorizedError,
|
|
|
|
|
+}))
|
|
|
|
|
+
|
|
|
|
|
+beforeEach(() => {
|
|
|
|
|
+ openShouldFail = false
|
|
|
|
|
+ openCalledWith = undefined
|
|
|
|
|
+ transportCalls.length = 0
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// Import modules after mocking
|
|
|
|
|
+const { MCP } = await import("../../src/mcp/index")
|
|
|
|
|
+const { Bus } = await import("../../src/bus")
|
|
|
|
|
+const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
|
|
|
|
+const { Instance } = await import("../../src/project/instance")
|
|
|
|
|
+const { tmpdir } = await import("../fixture/fixture")
|
|
|
|
|
+
|
|
|
|
|
+test("BrowserOpenFailed event is published when open() throws", async () => {
|
|
|
|
|
+ await using tmp = await tmpdir({
|
|
|
|
|
+ init: async (dir) => {
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ `${dir}/opencode.json`,
|
|
|
|
|
+ JSON.stringify({
|
|
|
|
|
+ $schema: "https://opencode.ai/config.json",
|
|
|
|
|
+ mcp: {
|
|
|
|
|
+ "test-oauth-server": {
|
|
|
|
|
+ type: "remote",
|
|
|
|
|
+ url: "https://example.com/mcp",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ await Instance.provide({
|
|
|
|
|
+ directory: tmp.path,
|
|
|
|
|
+ fn: async () => {
|
|
|
|
|
+ openShouldFail = true
|
|
|
|
|
+
|
|
|
|
|
+ const events: Array<{ mcpName: string; url: string }> = []
|
|
|
|
|
+ const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
|
|
|
|
|
+ events.push(evt.properties)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Run authenticate with a timeout to avoid waiting forever for the callback
|
|
|
|
|
+ const authPromise = MCP.authenticate("test-oauth-server")
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published)
|
|
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 200))
|
|
|
|
|
+
|
|
|
|
|
+ // Stop the callback server and cancel any pending auth
|
|
|
|
|
+ await McpOAuthCallback.stop()
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for authenticate to reject (due to server stopping)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await authPromise
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Expected to fail
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ unsubscribe()
|
|
|
|
|
+
|
|
|
|
|
+ // Verify the BrowserOpenFailed event was published
|
|
|
|
|
+ expect(events.length).toBe(1)
|
|
|
|
|
+ expect(events[0].mcpName).toBe("test-oauth-server")
|
|
|
|
|
+ expect(events[0].url).toContain("https://")
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+test("BrowserOpenFailed event is NOT published when open() succeeds", async () => {
|
|
|
|
|
+ await using tmp = await tmpdir({
|
|
|
|
|
+ init: async (dir) => {
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ `${dir}/opencode.json`,
|
|
|
|
|
+ JSON.stringify({
|
|
|
|
|
+ $schema: "https://opencode.ai/config.json",
|
|
|
|
|
+ mcp: {
|
|
|
|
|
+ "test-oauth-server-2": {
|
|
|
|
|
+ type: "remote",
|
|
|
|
|
+ url: "https://example.com/mcp",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ await Instance.provide({
|
|
|
|
|
+ directory: tmp.path,
|
|
|
|
|
+ fn: async () => {
|
|
|
|
|
+ openShouldFail = false
|
|
|
|
|
+
|
|
|
|
|
+ const events: Array<{ mcpName: string; url: string }> = []
|
|
|
|
|
+ const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
|
|
|
|
|
+ events.push(evt.properties)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Run authenticate with a timeout to avoid waiting forever for the callback
|
|
|
|
|
+ const authPromise = MCP.authenticate("test-oauth-server-2")
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for the browser open attempt and the 500ms error detection timeout
|
|
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 700))
|
|
|
|
|
+
|
|
|
|
|
+ // Stop the callback server and cancel any pending auth
|
|
|
|
|
+ await McpOAuthCallback.stop()
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for authenticate to reject (due to server stopping)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await authPromise
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Expected to fail
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ unsubscribe()
|
|
|
|
|
+
|
|
|
|
|
+ // Verify NO BrowserOpenFailed event was published
|
|
|
|
|
+ expect(events.length).toBe(0)
|
|
|
|
|
+ // Verify open() was still called
|
|
|
|
|
+ expect(openCalledWith).toBeDefined()
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+test("open() is called with the authorization URL", async () => {
|
|
|
|
|
+ await using tmp = await tmpdir({
|
|
|
|
|
+ init: async (dir) => {
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ `${dir}/opencode.json`,
|
|
|
|
|
+ JSON.stringify({
|
|
|
|
|
+ $schema: "https://opencode.ai/config.json",
|
|
|
|
|
+ mcp: {
|
|
|
|
|
+ "test-oauth-server-3": {
|
|
|
|
|
+ type: "remote",
|
|
|
|
|
+ url: "https://example.com/mcp",
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ await Instance.provide({
|
|
|
|
|
+ directory: tmp.path,
|
|
|
|
|
+ fn: async () => {
|
|
|
|
|
+ openShouldFail = false
|
|
|
|
|
+ openCalledWith = undefined
|
|
|
|
|
+
|
|
|
|
|
+ // Run authenticate with a timeout to avoid waiting forever for the callback
|
|
|
|
|
+ const authPromise = MCP.authenticate("test-oauth-server-3")
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for the browser open attempt and the 500ms error detection timeout
|
|
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 700))
|
|
|
|
|
+
|
|
|
|
|
+ // Stop the callback server and cancel any pending auth
|
|
|
|
|
+ await McpOAuthCallback.stop()
|
|
|
|
|
+
|
|
|
|
|
+ // Wait for authenticate to reject (due to server stopping)
|
|
|
|
|
+ try {
|
|
|
|
|
+ await authPromise
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // Expected to fail
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Verify open was called with a URL
|
|
|
|
|
+ expect(openCalledWith).toBeDefined()
|
|
|
|
|
+ expect(typeof openCalledWith).toBe("string")
|
|
|
|
|
+ expect(openCalledWith!).toContain("https://")
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+})
|