diff --git a/surfsense_web/tests/fixtures/connectors/linear.fixture.ts b/surfsense_web/tests/fixtures/connectors/linear.fixture.ts new file mode 100644 index 000000000..b7d716f28 --- /dev/null +++ b/surfsense_web/tests/fixtures/connectors/linear.fixture.ts @@ -0,0 +1,28 @@ +import { type ConnectorRow, deleteConnector, runLinearOAuth } from "../../helpers/api/connectors"; +import { searchSpaceFixtures } from "../search-space.fixture"; + +export type LinearFixtures = { + /** + * A pre-connected Linear connector inside the fixture's `searchSpace`. + * OAuth and MCP tool calls use E2E Linear fakes and are cleaned up + * automatically after the test. + */ + linearConnector: ConnectorRow; +}; + +export const linearFixtures = searchSpaceFixtures.extend({ + linearConnector: async ({ request, apiToken, searchSpace }, use) => { + const { connector } = await runLinearOAuth(request, apiToken, searchSpace.id); + if (!connector) { + throw new Error( + "linearConnector fixture: OAuth completed but no LINEAR_CONNECTOR was created. " + + "Check the backend log — the Linear MCP fake likely rejected an unmodelled call." + ); + } + try { + await use(connector); + } finally { + await deleteConnector(request, apiToken, connector.id); + } + }, +}); diff --git a/surfsense_web/tests/fixtures/index.ts b/surfsense_web/tests/fixtures/index.ts index 9f41adb73..dcd4ca327 100644 --- a/surfsense_web/tests/fixtures/index.ts +++ b/surfsense_web/tests/fixtures/index.ts @@ -22,6 +22,8 @@ * └─ nativeCalendarWithChatTest — chatThread * └─ notionFixtures — notionConnector * └─ notionWithChatTest — chatThread + * └─ linearFixtures — linearConnector + * └─ linearWithChatTest — chatThread * * To add a new connector (Gmail, Slack, manual upload, etc.): * 1. Add a fixture file under `fixtures/connectors/.fixture.ts`. @@ -33,6 +35,7 @@ export { chatThreadFixtures } from "./chat-thread.fixture"; export { composioCalendarFixtures } from "./connectors/composio-calendar.fixture"; export { composioDriveFixtures } from "./connectors/composio-drive.fixture"; export { composioGmailFixtures } from "./connectors/composio-gmail.fixture"; +export { linearFixtures } from "./connectors/linear.fixture"; export { nativeCalendarFixtures } from "./connectors/native-calendar.fixture"; export { nativeDriveFixtures } from "./connectors/native-drive.fixture"; export { nativeGmailFixtures } from "./connectors/native-gmail.fixture"; @@ -43,6 +46,7 @@ import { type ChatThreadFixtures, chatThreadFixtures } from "./chat-thread.fixtu import { composioCalendarFixtures } from "./connectors/composio-calendar.fixture"; import { composioDriveFixtures } from "./connectors/composio-drive.fixture"; import { composioGmailFixtures } from "./connectors/composio-gmail.fixture"; +import { linearFixtures } from "./connectors/linear.fixture"; import { nativeCalendarFixtures } from "./connectors/native-calendar.fixture"; import { nativeDriveFixtures } from "./connectors/native-drive.fixture"; import { nativeGmailFixtures } from "./connectors/native-gmail.fixture"; @@ -85,3 +89,7 @@ export const nativeCalendarWithChatTest = export const notionTest = notionFixtures; /** `test` for Notion specs that also need a chat thread. */ export const notionWithChatTest = notionFixtures.extend(chatThreadFixtures); +/** `test` for specs that also need a pre-connected Linear connector. */ +export const linearTest = linearFixtures; +/** `test` for Linear specs that also need a chat thread. */ +export const linearWithChatTest = linearFixtures.extend(chatThreadFixtures); diff --git a/surfsense_web/tests/helpers/api/connectors.ts b/surfsense_web/tests/helpers/api/connectors.ts index b650def57..34c3b5446 100644 --- a/surfsense_web/tests/helpers/api/connectors.ts +++ b/surfsense_web/tests/helpers/api/connectors.ts @@ -392,3 +392,53 @@ export async function runNotionOAuth( return { authUrl: auth_url, finalUrl: location, connector }; } + +/** + * Drives the Linear MCP OAuth flow programmatically. + * + * The E2E backend keeps SurfSense's generic MCP OAuth routes real and + * patches Linear's external discovery/DCR/token/MCP tool boundaries. + */ +export async function runLinearOAuth( + request: APIRequestContext, + token: string, + searchSpaceId: number +): Promise<{ + authUrl: string; + finalUrl: string; + connector: ConnectorRow | null; +}> { + const initiateResp = await request.get( + `${BACKEND_URL}/api/v1/auth/mcp/linear/connector/add?space_id=${searchSpaceId}`, + { headers: authHeaders(token) } + ); + if (!initiateResp.ok()) { + throw new Error( + `Linear MCP initiate failed (${initiateResp.status()}): ${await initiateResp.text()}` + ); + } + const { auth_url } = (await initiateResp.json()) as { auth_url: string }; + if (!auth_url) { + throw new Error("Linear MCP initiate response missing auth_url"); + } + + const state = new URL(auth_url).searchParams.get("state"); + if (!state) { + throw new Error(`Linear MCP auth_url missing state: ${auth_url}`); + } + + const callbackResp = await request.get( + `${BACKEND_URL}/api/v1/auth/mcp/linear/connector/callback?code=fake-linear-oauth-code&state=${encodeURIComponent(state)}`, + { + headers: authHeaders(token), + maxRedirects: 0, + failOnStatusCode: false, + } + ); + const location = callbackResp.headers().location ?? auth_url; + + const connectors = await listConnectors(request, token, searchSpaceId); + const connector = connectors.find((c) => c.connector_type === "LINEAR_CONNECTOR") ?? null; + + return { authUrl: auth_url, finalUrl: location, connector }; +}