mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +08:00
### What problem does this PR solve? This PR improves the connector dashboard task management experience and adds better visibility into connector execution logs. ### Overview: #### Before <img width="700" alt="image" src="https://github.com/user-attachments/assets/e4a8ed6f-2e18-4f0f-8528-41a514550052" /> #### Now: <img width="700" alt="Screenshot from 2026-05-18 16-31-30" src="https://github.com/user-attachments/assets/d4ca193b-847a-49ae-9e4f-5fbca60ea627" /> ### 1. Add a new logging page to the connector dashboard A new logging page has been added so users can view connector task execution logs directly from the connector dashboard. ### 2. Merge the Resume button into Confirm The separate **Resume** button has been removed. The **Confirm** button now represents different actions depending on the current task state: - **Save**: Save form changes and reschedule tasks. - **Stop**: Cancel currently scheduled or running tasks. - **Resume**: Create new scheduled tasks after the previous tasks have been stopped. - **Start**: Start tasks when no task has been started yet. ### 3. Separate syncing and pruning tasks Connector tasks are now separated into **syncing** and **pruning**. Pruning is controlled by the **Sync deleted files** option: - When **Sync deleted files** is disabled, only syncing tasks are shown. - When **Sync deleted files** is enabled, both syncing and pruning tasks are shown. **Now: Sync deleted files disabled** <img width="700" alt="Sync deleted files disabled" src="https://github.com/user-attachments/assets/dbd9232e-614a-407f-a0b1-c109e5fa567d" /> **Now: Sync deleted files enabled** <img width="700" alt="Sync deleted files enabled" src="https://github.com/user-attachments/assets/1f527f48-ccb3-4ee8-97ca-086891489296" /> ### 4. Update logs in backend <img width="700" alt="image" src="https://github.com/user-attachments/assets/10a95a3f-98c1-4e67-8afa-ddf6cda5b0b2" /> ### 5. Remove connector resume API - Removed: `POST /v1/connectors/<connector_id>/resume` - Replaced by: `PATCH /v1/connectors/<connector_id>` ### Type of change - [x] New Feature (non-breaking change which adds functionality)
756 lines
28 KiB
Python
756 lines
28 KiB
Python
#
|
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
|
|
import asyncio
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from types import ModuleType, SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
|
|
class _DummyManager:
|
|
def route(self, *_args, **_kwargs):
|
|
def decorator(func):
|
|
return func
|
|
|
|
return decorator
|
|
|
|
|
|
class _AwaitableValue:
|
|
def __init__(self, value):
|
|
self._value = value
|
|
|
|
def __await__(self):
|
|
async def _co():
|
|
return self._value
|
|
|
|
return _co().__await__()
|
|
|
|
|
|
class _Args(dict):
|
|
def get(self, key, default=None, type=None):
|
|
value = super().get(key, default)
|
|
if type is None:
|
|
return value
|
|
try:
|
|
return type(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
def to_dict(self, flat=True):
|
|
return dict(self)
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, body, status_code):
|
|
self.body = body
|
|
self.status_code = status_code
|
|
self.headers = {}
|
|
|
|
|
|
class _FakeConnectorRecord:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def to_dict(self):
|
|
return dict(self._payload)
|
|
|
|
|
|
class _FakeCredentials:
|
|
def __init__(self, raw='{"refresh_token":"rt","access_token":"at"}'):
|
|
self._raw = raw
|
|
|
|
def to_json(self):
|
|
return self._raw
|
|
|
|
|
|
class _FakeFlow:
|
|
def __init__(self, client_config, scopes):
|
|
self.client_config = client_config
|
|
self.scopes = scopes
|
|
self.redirect_uri = None
|
|
self.credentials = _FakeCredentials()
|
|
self.auth_kwargs = None
|
|
self.token_code = None
|
|
self.token_code_verifier = None
|
|
self.code_verifier = "fake-code-verifier"
|
|
|
|
def authorization_url(self, **kwargs):
|
|
self.auth_kwargs = dict(kwargs)
|
|
return f"https://oauth.example/{kwargs['state']}", kwargs["state"]
|
|
|
|
def fetch_token(self, code, code_verifier=None):
|
|
self.token_code = code
|
|
self.token_code_verifier = code_verifier
|
|
|
|
|
|
class _FakeBoxToken:
|
|
def __init__(self, access_token, refresh_token):
|
|
self.access_token = access_token
|
|
self.refresh_token = refresh_token
|
|
|
|
|
|
class _FakeBoxOAuth:
|
|
def __init__(self, config):
|
|
self.config = config
|
|
self.exchange_code = None
|
|
|
|
def get_authorize_url(self, options):
|
|
return f"https://box.example/auth?state={options.state}&redirect={options.redirect_uri}"
|
|
|
|
def get_tokens_authorization_code_grant(self, code):
|
|
self.exchange_code = code
|
|
|
|
def retrieve_token(self):
|
|
return _FakeBoxToken("box-access", "box-refresh")
|
|
|
|
|
|
class _FakeRedis:
|
|
def __init__(self):
|
|
self.store = {}
|
|
self.set_calls = []
|
|
self.deleted = []
|
|
|
|
def get(self, key):
|
|
return self.store.get(key)
|
|
|
|
def set_obj(self, key, obj, ttl):
|
|
self.set_calls.append((key, obj, ttl))
|
|
self.store[key] = json.dumps(obj)
|
|
|
|
def delete(self, key):
|
|
self.deleted.append(key)
|
|
self.store.pop(key, None)
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
def _set_request(module, *, args=None, json_body=None):
|
|
module.request = SimpleNamespace(
|
|
args=_Args(args or {}),
|
|
json=_AwaitableValue({} if json_body is None else json_body),
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def auth():
|
|
return "unit-auth"
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def set_tenant_info():
|
|
return None
|
|
|
|
|
|
def _load_connector_app(monkeypatch):
|
|
repo_root = Path(__file__).resolve().parents[3]
|
|
|
|
api_pkg = ModuleType("api")
|
|
api_pkg.__path__ = [str(repo_root / "api")]
|
|
monkeypatch.setitem(sys.modules, "api", api_pkg)
|
|
|
|
apps_mod = ModuleType("api.apps")
|
|
apps_mod.__path__ = [str(repo_root / "api" / "apps")]
|
|
apps_mod.current_user = SimpleNamespace(id="tenant-1")
|
|
apps_mod.login_required = lambda fn: fn
|
|
monkeypatch.setitem(sys.modules, "api.apps", apps_mod)
|
|
|
|
db_mod = ModuleType("api.db")
|
|
db_mod.InputType = SimpleNamespace(POLL="POLL")
|
|
monkeypatch.setitem(sys.modules, "api.db", db_mod)
|
|
|
|
services_pkg = ModuleType("api.db.services")
|
|
services_pkg.__path__ = []
|
|
monkeypatch.setitem(sys.modules, "api.db.services", services_pkg)
|
|
|
|
connector_service_mod = ModuleType("api.db.services.connector_service")
|
|
|
|
class _StubConnectorService:
|
|
@staticmethod
|
|
def update_by_id(*_args, **_kwargs):
|
|
return True
|
|
|
|
@staticmethod
|
|
def save(**_kwargs):
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_by_id(_connector_id):
|
|
return True, _FakeConnectorRecord({"id": _connector_id})
|
|
|
|
@staticmethod
|
|
def list(_tenant_id):
|
|
return []
|
|
|
|
@staticmethod
|
|
def accessible(*_args, **_kwargs):
|
|
return True
|
|
|
|
@staticmethod
|
|
def cancel_tasks(*_args, **_kwargs):
|
|
return True
|
|
|
|
@staticmethod
|
|
def rebuild(*_args, **_kwargs):
|
|
return None
|
|
|
|
@staticmethod
|
|
def delete_by_id(*_args, **_kwargs):
|
|
return True
|
|
|
|
class _StubSyncLogsService:
|
|
@staticmethod
|
|
def list_sync_tasks(*_args, **_kwargs):
|
|
return [], 0
|
|
|
|
connector_service_mod.ConnectorService = _StubConnectorService
|
|
connector_service_mod.SyncLogsService = _StubSyncLogsService
|
|
monkeypatch.setitem(sys.modules, "api.db.services.connector_service", connector_service_mod)
|
|
|
|
api_utils_mod = ModuleType("api.utils.api_utils")
|
|
|
|
async def _get_request_json():
|
|
return {}
|
|
|
|
api_utils_mod.get_request_json = _get_request_json
|
|
api_utils_mod.get_json_result = lambda data=None, message="", code=0: {
|
|
"code": code,
|
|
"message": message,
|
|
"data": data,
|
|
}
|
|
api_utils_mod.get_data_error_result = lambda message="", code=400, data=None: {
|
|
"code": code,
|
|
"message": message,
|
|
"data": data,
|
|
}
|
|
api_utils_mod.validate_request = lambda *_args, **_kwargs: (lambda fn: fn)
|
|
monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod)
|
|
|
|
constants_mod = ModuleType("common.constants")
|
|
constants_mod.RetCode = SimpleNamespace(
|
|
ARGUMENT_ERROR=101,
|
|
SERVER_ERROR=500,
|
|
RUNNING=102,
|
|
PERMISSION_ERROR=403,
|
|
AUTHENTICATION_ERROR=109,
|
|
)
|
|
constants_mod.TaskStatus = SimpleNamespace(
|
|
UNSTART="unstart",
|
|
SCHEDULE="schedule",
|
|
CANCEL="cancel",
|
|
)
|
|
monkeypatch.setitem(sys.modules, "common.constants", constants_mod)
|
|
|
|
config_mod = ModuleType("common.data_source.config")
|
|
config_mod.GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI = "https://example.com/drive"
|
|
config_mod.GMAIL_WEB_OAUTH_REDIRECT_URI = "https://example.com/gmail"
|
|
config_mod.BOX_WEB_OAUTH_REDIRECT_URI = "https://example.com/box"
|
|
config_mod.DocumentSource = SimpleNamespace(GMAIL="gmail", GOOGLE_DRIVE="google-drive")
|
|
monkeypatch.setitem(sys.modules, "common.data_source.config", config_mod)
|
|
|
|
google_constants_mod = ModuleType("common.data_source.google_util.constant")
|
|
google_constants_mod.WEB_OAUTH_POPUP_TEMPLATE = (
|
|
"<html><head><title>{title}</title></head>"
|
|
"<body><h1>{heading}</h1><p>{message}</p><script>{payload_json}</script><script>{auto_close}</script></body></html>"
|
|
)
|
|
google_constants_mod.GOOGLE_SCOPES = {
|
|
config_mod.DocumentSource.GMAIL: ["scope-gmail"],
|
|
config_mod.DocumentSource.GOOGLE_DRIVE: ["scope-drive"],
|
|
}
|
|
monkeypatch.setitem(sys.modules, "common.data_source.google_util.constant", google_constants_mod)
|
|
|
|
misc_mod = ModuleType("common.misc_utils")
|
|
misc_mod.get_uuid = lambda: "uuid-from-helper"
|
|
monkeypatch.setitem(sys.modules, "common.misc_utils", misc_mod)
|
|
|
|
rag_pkg = ModuleType("rag")
|
|
rag_pkg.__path__ = [str(repo_root / "rag")]
|
|
monkeypatch.setitem(sys.modules, "rag", rag_pkg)
|
|
|
|
rag_utils_pkg = ModuleType("rag.utils")
|
|
rag_utils_pkg.__path__ = [str(repo_root / "rag" / "utils")]
|
|
monkeypatch.setitem(sys.modules, "rag.utils", rag_utils_pkg)
|
|
|
|
redis_mod = ModuleType("rag.utils.redis_conn")
|
|
redis_mod.REDIS_CONN = _FakeRedis()
|
|
monkeypatch.setitem(sys.modules, "rag.utils.redis_conn", redis_mod)
|
|
|
|
quart_mod = ModuleType("quart")
|
|
quart_mod.request = SimpleNamespace(args=_Args(), json=_AwaitableValue({}))
|
|
|
|
async def _make_response(body, status_code):
|
|
return _FakeResponse(body, status_code)
|
|
|
|
quart_mod.make_response = _make_response
|
|
monkeypatch.setitem(sys.modules, "quart", quart_mod)
|
|
|
|
google_pkg = ModuleType("google_auth_oauthlib")
|
|
google_pkg.__path__ = []
|
|
monkeypatch.setitem(sys.modules, "google_auth_oauthlib", google_pkg)
|
|
|
|
google_flow_mod = ModuleType("google_auth_oauthlib.flow")
|
|
|
|
class _StubFlow:
|
|
@classmethod
|
|
def from_client_config(cls, client_config, scopes):
|
|
return _FakeFlow(client_config, scopes)
|
|
|
|
google_flow_mod.Flow = _StubFlow
|
|
monkeypatch.setitem(sys.modules, "google_auth_oauthlib.flow", google_flow_mod)
|
|
|
|
box_mod = ModuleType("box_sdk_gen")
|
|
|
|
class _OAuthConfig:
|
|
def __init__(self, client_id, client_secret):
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
|
|
class _GetAuthorizeUrlOptions:
|
|
def __init__(self, redirect_uri, state):
|
|
self.redirect_uri = redirect_uri
|
|
self.state = state
|
|
|
|
box_mod.BoxOAuth = _FakeBoxOAuth
|
|
box_mod.OAuthConfig = _OAuthConfig
|
|
box_mod.GetAuthorizeUrlOptions = _GetAuthorizeUrlOptions
|
|
monkeypatch.setitem(sys.modules, "box_sdk_gen", box_mod)
|
|
|
|
module_path = repo_root / "api" / "apps" / "restful_apis" / "connector_api.py"
|
|
spec = importlib.util.spec_from_file_location("test_connector_routes_unit", module_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
module.manager = _DummyManager()
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_connector_basic_routes_and_task_controls(monkeypatch):
|
|
module = _load_connector_app(monkeypatch)
|
|
|
|
async def _no_sleep(_secs):
|
|
return None
|
|
|
|
monkeypatch.setattr(module.asyncio, "sleep", _no_sleep)
|
|
|
|
records = {"conn-1": _FakeConnectorRecord({"id": "conn-1", "source": "drive"})}
|
|
update_calls = []
|
|
save_calls = []
|
|
cancel_calls = []
|
|
delete_calls = []
|
|
|
|
monkeypatch.setattr(module.ConnectorService, "update_by_id", lambda cid, payload: update_calls.append((cid, payload)))
|
|
|
|
def _save(**payload):
|
|
save_calls.append(payload)
|
|
records[payload["id"]] = _FakeConnectorRecord(payload)
|
|
|
|
monkeypatch.setattr(module.ConnectorService, "save", _save)
|
|
monkeypatch.setattr(module.ConnectorService, "get_by_id", lambda cid: (True, records[cid]))
|
|
monkeypatch.setattr(module.ConnectorService, "list", lambda tenant_id: [{"id": "listed", "tenant": tenant_id}])
|
|
monkeypatch.setattr(module.SyncLogsService, "list_sync_tasks", lambda cid, page, page_size: ([{"id": "log-1"}], 9))
|
|
monkeypatch.setattr(module.ConnectorService, "cancel_tasks", lambda cid: cancel_calls.append(cid))
|
|
monkeypatch.setattr(module.ConnectorService, "delete_by_id", lambda cid: delete_calls.append(cid))
|
|
monkeypatch.setattr(module, "get_uuid", lambda: "generated-id")
|
|
|
|
monkeypatch.setattr(
|
|
module,
|
|
"get_request_json",
|
|
lambda: _AwaitableValue({"id": "conn-1", "refresh_freq": 7, "config": {"x": 1}}),
|
|
)
|
|
res = _run(module.update_connector("conn-1"))
|
|
assert update_calls == [("conn-1", {'id': 'conn-1', "refresh_freq": 7, "config": {"x": 1}})]
|
|
assert res["data"]["id"] == "conn-1"
|
|
|
|
monkeypatch.setattr(
|
|
module,
|
|
"get_request_json",
|
|
lambda: _AwaitableValue({"name": "new", "source": "gmail", "config": {"y": 2}}),
|
|
)
|
|
res = _run(module.create_connector())
|
|
assert save_calls[-1]["id"] == "generated-id"
|
|
assert save_calls[-1]["tenant_id"] == "tenant-1"
|
|
assert save_calls[-1]["input_type"] == module.InputType.POLL
|
|
assert save_calls[-1]["status"] == module.TaskStatus.UNSTART
|
|
assert res["data"]["id"] == "generated-id"
|
|
|
|
list_res = module.list_connector()
|
|
assert list_res["data"] == [{"id": "listed", "tenant": "tenant-1"}]
|
|
|
|
monkeypatch.setattr(module.ConnectorService, "get_by_id", lambda _cid: (False, None))
|
|
missing_res = module.get_connector("missing")
|
|
assert missing_res["message"] == "Can't find this Connector!"
|
|
|
|
monkeypatch.setattr(module.ConnectorService, "get_by_id", lambda cid: (True, _FakeConnectorRecord({"id": cid})))
|
|
found_res = module.get_connector("conn-2")
|
|
assert found_res["data"]["id"] == "conn-2"
|
|
|
|
_set_request(module, args={"page": "2", "page_size": "7"})
|
|
logs_res = module.list_logs("conn-log")
|
|
assert logs_res["data"] == {"total": 9, "logs": [{"id": "log-1"}]}
|
|
|
|
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"kb_id": "kb-1"}))
|
|
monkeypatch.setattr(module.ConnectorService, "rebuild", lambda *_args: "rebuild-failed")
|
|
failed_rebuild = _run(module.rebuild("conn-rb"))
|
|
assert failed_rebuild["code"] == module.RetCode.SERVER_ERROR
|
|
assert failed_rebuild["data"] is False
|
|
|
|
monkeypatch.setattr(module.ConnectorService, "rebuild", lambda *_args: None)
|
|
ok_rebuild = _run(module.rebuild("conn-rb"))
|
|
assert ok_rebuild["data"] is True
|
|
|
|
rm_res = module.rm_connector("conn-rm")
|
|
assert rm_res["data"] is True
|
|
assert cancel_calls == ["conn-rm"]
|
|
assert delete_calls == ["conn-rm"]
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_connector_by_id_routes_reject_cross_tenant_access(monkeypatch):
|
|
"""Verify per-id connector routes stop before body parsing or service access."""
|
|
module = _load_connector_app(monkeypatch)
|
|
|
|
touched = []
|
|
monkeypatch.setattr(module.ConnectorService, "accessible", lambda cid, uid: False)
|
|
monkeypatch.setattr(module.ConnectorService, "get_by_id", lambda *_args: touched.append("get_by_id"))
|
|
monkeypatch.setattr(module.SyncLogsService, "list_sync_tasks", lambda *_args: touched.append("list_sync_tasks"))
|
|
monkeypatch.setattr(module.ConnectorService, "cancel_tasks", lambda *_args: touched.append("cancel_tasks"))
|
|
monkeypatch.setattr(module.ConnectorService, "delete_by_id", lambda *_args: touched.append("delete_by_id"))
|
|
monkeypatch.setattr(module.ConnectorService, "update_by_id", lambda *_args: touched.append("update_by_id"))
|
|
monkeypatch.setattr(module.ConnectorService, "rebuild", lambda *_args: touched.append("rebuild"))
|
|
|
|
def _get_request_json():
|
|
touched.append("get_request_json")
|
|
return _AwaitableValue({"config": {"x": 1}})
|
|
|
|
monkeypatch.setattr(module, "get_request_json", _get_request_json)
|
|
|
|
responses = [
|
|
_run(module.update_connector("conn-victim")),
|
|
module.get_connector("conn-victim"),
|
|
module.list_logs("conn-victim"),
|
|
_run(module.rebuild("conn-victim")),
|
|
module.rm_connector("conn-victim"),
|
|
_run(module.test_connector("conn-victim")),
|
|
]
|
|
|
|
assert all(res["code"] == module.RetCode.AUTHENTICATION_ERROR for res in responses)
|
|
assert all(res["message"] == "No authorization." for res in responses)
|
|
assert all(res["data"] is False for res in responses)
|
|
assert touched == []
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_connector_oauth_helper_functions(monkeypatch):
|
|
module = _load_connector_app(monkeypatch)
|
|
|
|
assert module._web_state_cache_key("flow-a", "gmail") == "gmail_web_flow_state:flow-a"
|
|
assert module._web_result_cache_key("flow-b", "google-drive") == "google-drive_web_flow_result:flow-b"
|
|
|
|
creds_dict = {"web": {"client_id": "id"}}
|
|
assert module._load_credentials(creds_dict) == creds_dict
|
|
assert module._load_credentials(json.dumps(creds_dict)) == creds_dict
|
|
|
|
with pytest.raises(ValueError, match="Invalid Google credentials JSON"):
|
|
module._load_credentials("{not-json")
|
|
|
|
assert module._get_web_client_config(creds_dict) == {"web": {"client_id": "id"}}
|
|
with pytest.raises(ValueError, match="must include a 'web'"):
|
|
module._get_web_client_config({"installed": {"client_id": "id"}})
|
|
|
|
popup_ok = _run(module._render_web_oauth_popup("flow-1", True, "done", "gmail"))
|
|
assert popup_ok.status_code == 200
|
|
assert popup_ok.headers["Content-Type"] == "text/html; charset=utf-8"
|
|
assert "Authorization complete" in popup_ok.body
|
|
assert "ragflow-gmail-oauth" in popup_ok.body
|
|
|
|
popup_error = _run(module._render_web_oauth_popup("flow-2", False, "<denied>", "google-drive"))
|
|
assert popup_error.status_code == 200
|
|
assert "Authorization failed" in popup_error.body
|
|
assert "<denied>" in popup_error.body
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_start_google_web_oauth_matrix(monkeypatch):
|
|
module = _load_connector_app(monkeypatch)
|
|
|
|
redis = _FakeRedis()
|
|
monkeypatch.setattr(module, "REDIS_CONN", redis)
|
|
monkeypatch.setattr(module.time, "time", lambda: 1700000000)
|
|
|
|
flow_calls = []
|
|
|
|
def _from_client_config(client_config, scopes):
|
|
flow = _FakeFlow(client_config, scopes)
|
|
flow_calls.append(flow)
|
|
return flow
|
|
|
|
monkeypatch.setattr(module.Flow, "from_client_config", staticmethod(_from_client_config))
|
|
|
|
_set_request(module, args={"type": "invalid"})
|
|
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"credentials": "{}"}))
|
|
invalid_type = _run(module.start_google_web_oauth())
|
|
assert invalid_type["code"] == module.RetCode.ARGUMENT_ERROR
|
|
|
|
monkeypatch.setattr(module, "GMAIL_WEB_OAUTH_REDIRECT_URI", "")
|
|
_set_request(module, args={"type": "gmail"})
|
|
missing_redirect = _run(module.start_google_web_oauth())
|
|
assert missing_redirect["code"] == module.RetCode.SERVER_ERROR
|
|
|
|
monkeypatch.setattr(module, "GMAIL_WEB_OAUTH_REDIRECT_URI", "https://example.com/gmail")
|
|
monkeypatch.setattr(module, "GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI", "https://example.com/drive")
|
|
|
|
_set_request(module, args={"type": "google-drive"})
|
|
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"credentials": "{invalid-json"}))
|
|
invalid_credentials = _run(module.start_google_web_oauth())
|
|
assert invalid_credentials["code"] == module.RetCode.ARGUMENT_ERROR
|
|
|
|
monkeypatch.setattr(
|
|
module,
|
|
"get_request_json",
|
|
lambda: _AwaitableValue({"credentials": json.dumps({"web": {"client_id": "id"}, "refresh_token": "rt"})}),
|
|
)
|
|
has_refresh_token = _run(module.start_google_web_oauth())
|
|
assert has_refresh_token["code"] == module.RetCode.ARGUMENT_ERROR
|
|
|
|
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"credentials": json.dumps({"installed": {"x": 1}})}))
|
|
missing_web = _run(module.start_google_web_oauth())
|
|
assert missing_web["code"] == module.RetCode.ARGUMENT_ERROR
|
|
|
|
ids = iter(["flow-gmail", "flow-drive"])
|
|
monkeypatch.setattr(module.uuid, "uuid4", lambda: next(ids))
|
|
|
|
monkeypatch.setattr(
|
|
module,
|
|
"get_request_json",
|
|
lambda: _AwaitableValue({"credentials": json.dumps({"web": {"client_id": "id", "client_secret": "secret"}})}),
|
|
)
|
|
|
|
_set_request(module, args={"type": "gmail"})
|
|
gmail_ok = _run(module.start_google_web_oauth())
|
|
assert gmail_ok["code"] == 0
|
|
assert gmail_ok["data"]["flow_id"] == "flow-gmail"
|
|
assert gmail_ok["data"]["authorization_url"].endswith("flow-gmail")
|
|
|
|
_set_request(module, args={})
|
|
drive_ok = _run(module.start_google_web_oauth())
|
|
assert drive_ok["code"] == 0
|
|
assert drive_ok["data"]["flow_id"] == "flow-drive"
|
|
assert drive_ok["data"]["authorization_url"].endswith("flow-drive")
|
|
|
|
assert any(call.scopes == module.GOOGLE_SCOPES[module.DocumentSource.GMAIL] for call in flow_calls)
|
|
assert any(call.scopes == module.GOOGLE_SCOPES[module.DocumentSource.GOOGLE_DRIVE] for call in flow_calls)
|
|
assert "gmail_web_flow_state:flow-gmail" in redis.store
|
|
assert "google-drive_web_flow_state:flow-drive" in redis.store
|
|
assert json.loads(redis.store["gmail_web_flow_state:flow-gmail"])["code_verifier"] == "fake-code-verifier"
|
|
assert json.loads(redis.store["google-drive_web_flow_state:flow-drive"])["code_verifier"] == "fake-code-verifier"
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_google_web_oauth_callbacks_matrix(monkeypatch):
|
|
module = _load_connector_app(monkeypatch)
|
|
|
|
flow_calls = []
|
|
|
|
def _from_client_config(client_config, scopes):
|
|
flow = _FakeFlow(client_config, scopes)
|
|
flow_calls.append(flow)
|
|
return flow
|
|
|
|
monkeypatch.setattr(module.Flow, "from_client_config", staticmethod(_from_client_config))
|
|
|
|
callback_specs = [
|
|
(
|
|
module.google_gmail_web_oauth_callback,
|
|
"gmail",
|
|
module.GMAIL_WEB_OAUTH_REDIRECT_URI,
|
|
module.GOOGLE_SCOPES[module.DocumentSource.GMAIL],
|
|
),
|
|
(
|
|
module.google_drive_web_oauth_callback,
|
|
"google-drive",
|
|
module.GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI,
|
|
module.GOOGLE_SCOPES[module.DocumentSource.GOOGLE_DRIVE],
|
|
),
|
|
]
|
|
|
|
for callback, source, expected_redirect, expected_scopes in callback_specs:
|
|
redis = _FakeRedis()
|
|
monkeypatch.setattr(module, "REDIS_CONN", redis)
|
|
|
|
_set_request(module, args={})
|
|
missing_state = _run(callback())
|
|
assert "Missing OAuth state parameter." in missing_state.body
|
|
|
|
_set_request(module, args={"state": "sid"})
|
|
expired_state = _run(callback())
|
|
assert "Authorization session expired" in expired_state.body
|
|
|
|
redis.store[module._web_state_cache_key("sid", source)] = json.dumps({"user_id": "tenant-1"})
|
|
_set_request(module, args={"state": "sid"})
|
|
invalid_state = _run(callback())
|
|
assert "Authorization session was invalid" in invalid_state.body
|
|
assert module._web_state_cache_key("sid", source) in redis.deleted
|
|
|
|
redis.store[module._web_state_cache_key("sid", source)] = json.dumps({
|
|
"user_id": "tenant-1",
|
|
"client_config": {"web": {"client_id": "cid"}},
|
|
})
|
|
_set_request(module, args={"state": "sid", "error": "denied", "error_description": "permission denied"})
|
|
oauth_error = _run(callback())
|
|
assert "permission denied" in oauth_error.body
|
|
|
|
redis.store[module._web_state_cache_key("sid", source)] = json.dumps({
|
|
"user_id": "tenant-1",
|
|
"client_config": {"web": {"client_id": "cid"}},
|
|
})
|
|
_set_request(module, args={"state": "sid"})
|
|
missing_code = _run(callback())
|
|
assert "Missing authorization code" in missing_code.body
|
|
|
|
redis.store[module._web_state_cache_key("sid", source)] = json.dumps({
|
|
"user_id": "tenant-1",
|
|
"client_config": {"web": {"client_id": "cid"}},
|
|
"code_verifier": "state-code-verifier",
|
|
})
|
|
_set_request(module, args={"state": "sid", "code": "code-123"})
|
|
success = _run(callback())
|
|
assert "Authorization completed successfully." in success.body
|
|
|
|
result_key = module._web_result_cache_key("sid", source)
|
|
assert result_key in redis.store
|
|
assert module._web_state_cache_key("sid", source) in redis.deleted
|
|
|
|
assert flow_calls[-1].redirect_uri == expected_redirect
|
|
assert flow_calls[-1].scopes == expected_scopes
|
|
assert flow_calls[-1].token_code == "code-123"
|
|
assert flow_calls[-1].token_code_verifier == "state-code-verifier"
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_poll_google_web_result_matrix(monkeypatch):
|
|
module = _load_connector_app(monkeypatch)
|
|
redis = _FakeRedis()
|
|
monkeypatch.setattr(module, "REDIS_CONN", redis)
|
|
|
|
_set_request(module, args={"type": "invalid"}, json_body={"flow_id": "flow-1"})
|
|
invalid_type = _run(module.poll_google_web_result())
|
|
assert invalid_type["code"] == module.RetCode.ARGUMENT_ERROR
|
|
|
|
_set_request(module, args={"type": "gmail"}, json_body={"flow_id": "flow-1"})
|
|
pending = _run(module.poll_google_web_result())
|
|
assert pending["code"] == module.RetCode.RUNNING
|
|
|
|
redis.store[module._web_result_cache_key("flow-1", "gmail")] = json.dumps(
|
|
{"user_id": "another-user", "credentials": "token-x"}
|
|
)
|
|
_set_request(module, args={"type": "gmail"}, json_body={"flow_id": "flow-1"})
|
|
permission_error = _run(module.poll_google_web_result())
|
|
assert permission_error["code"] == module.RetCode.PERMISSION_ERROR
|
|
|
|
redis.store[module._web_result_cache_key("flow-1", "gmail")] = json.dumps(
|
|
{"user_id": "tenant-1", "credentials": "token-ok"}
|
|
)
|
|
_set_request(module, args={"type": "gmail"}, json_body={"flow_id": "flow-1"})
|
|
success = _run(module.poll_google_web_result())
|
|
assert success["code"] == 0
|
|
assert success["data"] == {"credentials": "token-ok"}
|
|
assert module._web_result_cache_key("flow-1", "gmail") in redis.deleted
|
|
|
|
|
|
@pytest.mark.p2
|
|
def test_box_oauth_start_callback_and_poll_matrix(monkeypatch):
|
|
module = _load_connector_app(monkeypatch)
|
|
redis = _FakeRedis()
|
|
monkeypatch.setattr(module, "REDIS_CONN", redis)
|
|
|
|
created_auth = []
|
|
|
|
class _TrackingBoxOAuth(_FakeBoxOAuth):
|
|
def __init__(self, config):
|
|
super().__init__(config)
|
|
created_auth.append(self)
|
|
|
|
monkeypatch.setattr(module, "BoxOAuth", _TrackingBoxOAuth)
|
|
monkeypatch.setattr(module.uuid, "uuid4", lambda: "flow-box")
|
|
monkeypatch.setattr(module.time, "time", lambda: 1800000000)
|
|
|
|
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({}))
|
|
missing_params = _run(module.start_box_web_oauth())
|
|
assert missing_params["code"] == module.RetCode.ARGUMENT_ERROR
|
|
|
|
monkeypatch.setattr(
|
|
module,
|
|
"get_request_json",
|
|
lambda: _AwaitableValue({"client_id": "cid", "client_secret": "sec", "redirect_uri": "https://box.local/callback"}),
|
|
)
|
|
start_ok = _run(module.start_box_web_oauth())
|
|
assert start_ok["code"] == 0
|
|
assert start_ok["data"]["flow_id"] == "flow-box"
|
|
assert "authorization_url" in start_ok["data"]
|
|
assert module._web_state_cache_key("flow-box", "box") in redis.store
|
|
|
|
_set_request(module, args={})
|
|
missing_state = _run(module.box_web_oauth_callback())
|
|
assert "Missing OAuth parameters." in missing_state.body
|
|
|
|
_set_request(module, args={"state": "flow-box"})
|
|
missing_code = _run(module.box_web_oauth_callback())
|
|
assert "Missing authorization code from Box." in missing_code.body
|
|
|
|
redis.store[module._web_state_cache_key("flow-null", "box")] = "null"
|
|
_set_request(module, args={"state": "flow-null", "code": "abc"})
|
|
invalid_session = _run(module.box_web_oauth_callback())
|
|
assert invalid_session["code"] == module.RetCode.ARGUMENT_ERROR
|
|
|
|
redis.store[module._web_state_cache_key("flow-box", "box")] = json.dumps(
|
|
{"user_id": "tenant-1", "client_id": "cid", "client_secret": "sec"}
|
|
)
|
|
_set_request(module, args={"state": "flow-box", "code": "abc", "error": "access_denied", "error_description": "denied"})
|
|
callback_error = _run(module.box_web_oauth_callback())
|
|
assert "denied" in callback_error.body
|
|
|
|
redis.store[module._web_state_cache_key("flow-ok", "box")] = json.dumps(
|
|
{"user_id": "tenant-1", "client_id": "cid", "client_secret": "sec"}
|
|
)
|
|
_set_request(module, args={"state": "flow-ok", "code": "code-ok"})
|
|
callback_success = _run(module.box_web_oauth_callback())
|
|
assert "Authorization completed successfully." in callback_success.body
|
|
assert created_auth[-1].exchange_code == "code-ok"
|
|
assert module._web_result_cache_key("flow-ok", "box") in redis.store
|
|
assert module._web_state_cache_key("flow-ok", "box") in redis.deleted
|
|
|
|
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"flow_id": "flow-ok"}))
|
|
redis.store.pop(module._web_result_cache_key("flow-ok", "box"), None)
|
|
pending = _run(module.poll_box_web_result())
|
|
assert pending["code"] == module.RetCode.RUNNING
|
|
|
|
redis.store[module._web_result_cache_key("flow-ok", "box")] = json.dumps({"user_id": "another-user"})
|
|
permission_error = _run(module.poll_box_web_result())
|
|
assert permission_error["code"] == module.RetCode.PERMISSION_ERROR
|
|
|
|
redis.store[module._web_result_cache_key("flow-ok", "box")] = json.dumps(
|
|
{"user_id": "tenant-1", "access_token": "at", "refresh_token": "rt"}
|
|
)
|
|
poll_success = _run(module.poll_box_web_result())
|
|
assert poll_success["code"] == 0
|
|
assert poll_success["data"]["credentials"]["access_token"] == "at"
|
|
assert module._web_result_cache_key("flow-ok", "box") in redis.deleted
|