From 640ae03030b335febc66d6b1ed96b6b73a867d38 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 8 May 2026 01:02:52 +0530 Subject: [PATCH] test(backend): add Confluence OAuth E2E fakes --- .../tests/e2e/fakes/confluence_oauth.py | 144 ++++++++++++++++++ .../e2e/fakes/fixtures/confluence_pages.json | 33 ++++ .../tests/e2e/fakes/notion_module.py | 13 +- surfsense_backend/tests/e2e/run_backend.py | 11 ++ surfsense_backend/tests/e2e/run_celery.py | 11 ++ 5 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 surfsense_backend/tests/e2e/fakes/confluence_oauth.py create mode 100644 surfsense_backend/tests/e2e/fakes/fixtures/confluence_pages.json diff --git a/surfsense_backend/tests/e2e/fakes/confluence_oauth.py b/surfsense_backend/tests/e2e/fakes/confluence_oauth.py new file mode 100644 index 000000000..df3859e2f --- /dev/null +++ b/surfsense_backend/tests/e2e/fakes/confluence_oauth.py @@ -0,0 +1,144 @@ +"""Strict Confluence OAuth fakes for Playwright E2E.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +_FIXTURE_PATH = Path(__file__).parent / "fixtures" / "confluence_pages.json" + +_TOKEN_URL = "https://auth.atlassian.com/oauth/token" +_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources" +_ACCESS_TOKEN = "fake-confluence-access-token" +_REFRESH_TOKEN = "fake-confluence-refresh-token" +_OAUTH_CODE = "fake-confluence-oauth-code" + + +def _load_fixture() -> dict[str, Any]: + with _FIXTURE_PATH.open() as f: + return json.load(f) + + +_FIXTURE = _load_fixture() + + +class _StrictFakeMixin: + _component_name: str = "" + + def __getattr__(self, name: str) -> Any: + raise NotImplementedError( + f"E2E Confluence OAuth fake missing surface: " + f"{self._component_name}.{name!r}. " + "Add it to surfsense_backend/tests/e2e/fakes/confluence_oauth.py." + ) + + +class _FakeResponse(_StrictFakeMixin): + _component_name = "httpx.Response" + + def __init__(self, payload: Any, status_code: int = 200): + self._payload = payload + self.status_code = status_code + self.text = json.dumps(payload, sort_keys=True) + + def json(self) -> Any: + return self._payload + + +class _FakeHttpxAsyncClient(_StrictFakeMixin): + _component_name = "httpx.AsyncClient" + + def __init__(self, *args: Any, **kwargs: Any): + del args, kwargs + + async def __aenter__(self) -> _FakeHttpxAsyncClient: + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + del exc_type, exc, tb + + async def post(self, url: str, **kwargs: Any) -> _FakeResponse: + if url != _TOKEN_URL: + raise NotImplementedError(f"Unexpected Confluence OAuth POST url={url!r}") + + data = kwargs.get("json") or {} + headers = kwargs.get("headers") or {} + if headers.get("Content-Type") != "application/json": + raise ValueError("Confluence OAuth token exchange expected JSON headers.") + + grant_type = data.get("grant_type") + if grant_type == "authorization_code": + if data.get("code") != _OAUTH_CODE: + raise ValueError( + f"Unexpected fake Confluence OAuth code: {data.get('code')!r}" + ) + if not data.get("client_id") or not data.get("client_secret"): + raise ValueError("Confluence OAuth token exchange missing client creds.") + if "/api/v1/auth/confluence/connector/callback" not in str( + data.get("redirect_uri", "") + ): + raise ValueError( + "Confluence OAuth token exchange got unexpected redirect_uri: " + f"{data.get('redirect_uri')!r}" + ) + elif grant_type == "refresh_token": + if data.get("refresh_token") != _REFRESH_TOKEN: + raise ValueError( + "Unexpected fake Confluence refresh token: " + f"{data.get('refresh_token')!r}" + ) + else: + raise ValueError(f"Unexpected fake Confluence grant_type: {grant_type!r}") + + return _FakeResponse( + { + "access_token": _ACCESS_TOKEN, + "refresh_token": _REFRESH_TOKEN, + "expires_in": 3600, + "scope": "read:confluence-user read:space:confluence read:page:confluence", + "token_type": "Bearer", + } + ) + + async def get(self, url: str, **kwargs: Any) -> _FakeResponse: + if url != _RESOURCES_URL: + raise NotImplementedError(f"Unexpected Confluence OAuth GET url={url!r}") + + headers = kwargs.get("headers") or {} + auth = headers.get("Authorization") + if auth != f"Bearer {_ACCESS_TOKEN}": + raise ValueError(f"Unexpected Confluence resources Authorization: {auth!r}") + + site = _FIXTURE["site"] + return _FakeResponse( + [ + { + "id": site["cloud_id"], + "name": site["name"], + "url": site["url"], + "scopes": [ + "read:confluence-user", + "read:space:confluence", + "read:page:confluence", + ], + } + ] + ) + + +class _FakeHttpxModule(_StrictFakeMixin): + _component_name = "httpx" + + AsyncClient = _FakeHttpxAsyncClient + + +def install(active_patches: list[Any]) -> None: + """Patch only Confluence route-local HTTP OAuth calls.""" + p = patch( + "app.routes.confluence_add_connector_route.httpx", + _FakeHttpxModule(), + ) + p.start() + active_patches.append(p) diff --git a/surfsense_backend/tests/e2e/fakes/fixtures/confluence_pages.json b/surfsense_backend/tests/e2e/fakes/fixtures/confluence_pages.json new file mode 100644 index 000000000..1a9a533c2 --- /dev/null +++ b/surfsense_backend/tests/e2e/fakes/fixtures/confluence_pages.json @@ -0,0 +1,33 @@ +{ + "site": { + "cloud_id": "fake-confluence-cloud-001", + "name": "SurfSense E2E Confluence", + "url": "https://surfsense-e2e-confluence.atlassian.net" + }, + "pages": [ + { + "id": "fake-confluence-page-canary-001", + "title": "E2E Canary Confluence Page", + "spaceId": "fake-confluence-space-001", + "body": { + "storage": { + "value": "

E2E Canary Confluence Page

This page proves Confluence OAuth indexing works end-to-end. SURFSENSE_E2E_CANARY_TOKEN_CONFLUENCE_001

" + } + }, + "comments": [ + { + "id": "fake-confluence-comment-canary-001", + "body": { + "storage": { + "value": "

Confluence comment content is included in indexed markdown.

" + } + }, + "version": { + "authorId": "fake-confluence-user-001", + "createdAt": "2026-05-08T00:00:00.000Z" + } + } + ] + } + ] +} diff --git a/surfsense_backend/tests/e2e/fakes/notion_module.py b/surfsense_backend/tests/e2e/fakes/notion_module.py index 4a7598726..6ad2bc1af 100644 --- a/surfsense_backend/tests/e2e/fakes/notion_module.py +++ b/surfsense_backend/tests/e2e/fakes/notion_module.py @@ -139,6 +139,9 @@ class _FakeTokenResponse(_StrictFakeMixin): class _FakeHttpxAsyncClient(_StrictFakeMixin): _component_name = "httpx.AsyncClient" + def __init__(self, *args: Any, **kwargs: Any): + del args, kwargs + async def __aenter__(self) -> _FakeHttpxAsyncClient: return self @@ -183,12 +186,18 @@ class _FakeHttpxAsyncClient(_StrictFakeMixin): ) +class _FakeHttpxModule(_StrictFakeMixin): + _component_name = "httpx" + + AsyncClient = _FakeHttpxAsyncClient + + def install(active_patches: list[Any]) -> None: """Patch production bindings that cannot be covered by sys.modules hijack.""" targets = [ ( - "app.routes.notion_add_connector_route.httpx.AsyncClient", - _FakeHttpxAsyncClient, + "app.routes.notion_add_connector_route.httpx", + _FakeHttpxModule(), ), ] for target, replacement in targets: diff --git a/surfsense_backend/tests/e2e/run_backend.py b/surfsense_backend/tests/e2e/run_backend.py index 7158811aa..5ad4ec03d 100644 --- a/surfsense_backend/tests/e2e/run_backend.py +++ b/surfsense_backend/tests/e2e/run_backend.py @@ -57,6 +57,12 @@ sys.modules["notion_client.errors"] = _fake_notion.errors from dotenv import load_dotenv # noqa: E402 load_dotenv() +os.environ.setdefault("ATLASSIAN_CLIENT_ID", "fake-atlassian-client-id") +os.environ.setdefault("ATLASSIAN_CLIENT_SECRET", "fake-atlassian-client-secret") +os.environ.setdefault( + "CONFLUENCE_REDIRECT_URI", + "http://localhost:8000/api/v1/auth/confluence/connector/callback", +) os.environ.setdefault("NOTION_CLIENT_ID", "fake-notion-client-id") os.environ.setdefault("NOTION_CLIENT_SECRET", "fake-notion-client-secret") os.environ.setdefault( @@ -89,6 +95,8 @@ from unittest.mock import patch # noqa: E402 from app.app import app # noqa: E402 from tests.e2e.fakes import ( # noqa: E402 + confluence_indexer as _fake_confluence_indexer, + confluence_oauth as _fake_confluence_oauth, embeddings as _fake_embeddings, jira_module as _fake_jira_module, linear_module as _fake_linear_module, @@ -110,6 +118,7 @@ def _patch_llm_bindings() -> None: """Replace LLM factories at every known binding site.""" targets = [ "app.services.llm_service.get_user_long_context_llm", + "app.tasks.connector_indexers.confluence_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.google_drive_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.google_gmail_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.notion_indexer.get_user_long_context_llm", @@ -164,6 +173,8 @@ def _patch_llm_bindings() -> None: _patch_llm_bindings() _fake_embeddings.install(_active_patches) +_fake_confluence_oauth.install(_active_patches) +_fake_confluence_indexer.install(_active_patches) _fake_native_google.install(_active_patches) _fake_notion_module.install(_active_patches) _fake_linear_module.install(_active_patches) diff --git a/surfsense_backend/tests/e2e/run_celery.py b/surfsense_backend/tests/e2e/run_celery.py index 1794bc569..326882303 100644 --- a/surfsense_backend/tests/e2e/run_celery.py +++ b/surfsense_backend/tests/e2e/run_celery.py @@ -44,6 +44,12 @@ sys.modules["notion_client.errors"] = _fake_notion.errors from dotenv import load_dotenv # noqa: E402 load_dotenv() +os.environ.setdefault("ATLASSIAN_CLIENT_ID", "fake-atlassian-client-id") +os.environ.setdefault("ATLASSIAN_CLIENT_SECRET", "fake-atlassian-client-secret") +os.environ.setdefault( + "CONFLUENCE_REDIRECT_URI", + "http://localhost:8000/api/v1/auth/confluence/connector/callback", +) os.environ.setdefault("NOTION_CLIENT_ID", "fake-notion-client-id") os.environ.setdefault("NOTION_CLIENT_SECRET", "fake-notion-client-secret") os.environ.setdefault( @@ -74,6 +80,8 @@ from unittest.mock import patch # noqa: E402 from app.celery_app import celery_app # noqa: E402 from tests.e2e.fakes import ( # noqa: E402 + confluence_indexer as _fake_confluence_indexer, + confluence_oauth as _fake_confluence_oauth, embeddings as _fake_embeddings, jira_module as _fake_jira_module, linear_module as _fake_linear_module, @@ -94,6 +102,7 @@ _active_patches: list = [] def _patch_llm_bindings() -> None: targets = [ "app.services.llm_service.get_user_long_context_llm", + "app.tasks.connector_indexers.confluence_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.google_drive_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.google_gmail_indexer.get_user_long_context_llm", "app.tasks.connector_indexers.notion_indexer.get_user_long_context_llm", @@ -148,6 +157,8 @@ def _patch_llm_bindings() -> None: _patch_llm_bindings() _fake_embeddings.install(_active_patches) +_fake_confluence_oauth.install(_active_patches) +_fake_confluence_indexer.install(_active_patches) _fake_native_google.install(_active_patches) _fake_notion_module.install(_active_patches) _fake_linear_module.install(_active_patches)