mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 14:17:08 +00:00
Connections own their credentials
Migration 0038 moves every stored secret (OAuth tokens, MCP OAuth tokens and client registrations, API keys) into the connection's encrypted envelope, links API-key tools to one connection per distinct credential, allows several accounts per provider, and adds credential_mode to sources and tools. OAuth MCP tools keep resolving each member's own token, as they did before. docsgpt.connectors.service is now the only reader of OAuth tokens: get_valid_token_info refreshes under a row lock and persists rotated refresh tokens, and a revoked grant flags the connection, pauses its sources and notifies the owner. Loaders build from a connection (BaseConnectorLoader.from_connection), so scheduled sync covers Drive, SharePoint and Confluence sources with no browser. S3 and Reddit keys stay on the connection instead of in remote_data. New endpoints: POST /api/connections, /setup, /reconnect, /picker-token, /claim, DELETE /api/connections/<id>, per-action permissions and MCP refresh-tools. Upload, file listing, sync and validate-session take a connection_id; session tokens keep working for this release. The tool executor reads credentials from the resolved connection (owner or member mode) and pauses on a Connect card when a connection needs signing in. docsgpt connectors reencrypt rewrites stored credentials after a key rotation.
This commit is contained in:
1 parent
667d4bbab0
commit
3511004f50
54 files changed
+4007
-754
No files matched your search
@@ -3,7 +3,6 @@
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -18,82 +17,3 @@ def _patch_db(conn):
|
||||
yield
|
||||
|
||||
|
||||
class TestGetTokenInfoFromSession:
|
||||
def test_invalid_session_token_raises(self, pg_conn):
|
||||
from docsgpt.parser.connectors.confluence.auth import (
|
||||
ConfluenceAuth,
|
||||
)
|
||||
|
||||
auth = ConfluenceAuth.__new__(ConfluenceAuth)
|
||||
with _patch_db(pg_conn), pytest.raises(ValueError):
|
||||
auth.get_token_info_from_session("no-such-token")
|
||||
|
||||
def test_missing_token_info_raises(self, pg_conn):
|
||||
from docsgpt.parser.connectors.confluence.auth import (
|
||||
ConfluenceAuth,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.connector_sessions import (
|
||||
ConnectorSessionsRepository,
|
||||
)
|
||||
|
||||
repo = ConnectorSessionsRepository(pg_conn)
|
||||
repo.upsert("u", "confluence", status="authorized")
|
||||
# Set session_token but no token_info
|
||||
session = repo.get_by_user_provider("u", "confluence")
|
||||
repo.update(str(session["id"]), {"session_token": "tok-no-info"})
|
||||
|
||||
auth = ConfluenceAuth.__new__(ConfluenceAuth)
|
||||
with _patch_db(pg_conn), pytest.raises(ValueError):
|
||||
auth.get_token_info_from_session("tok-no-info")
|
||||
|
||||
def test_missing_required_fields_raises(self, pg_conn):
|
||||
from docsgpt.parser.connectors.confluence.auth import (
|
||||
ConfluenceAuth,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.connector_sessions import (
|
||||
ConnectorSessionsRepository,
|
||||
)
|
||||
|
||||
repo = ConnectorSessionsRepository(pg_conn)
|
||||
repo.upsert("u", "confluence", status="authorized")
|
||||
session = repo.get_by_user_provider("u", "confluence")
|
||||
repo.update(
|
||||
str(session["id"]),
|
||||
{
|
||||
"session_token": "tok-partial",
|
||||
"token_info": {"access_token": "at"}, # missing refresh + cloud_id
|
||||
},
|
||||
)
|
||||
|
||||
auth = ConfluenceAuth.__new__(ConfluenceAuth)
|
||||
with _patch_db(pg_conn), pytest.raises(ValueError):
|
||||
auth.get_token_info_from_session("tok-partial")
|
||||
|
||||
def test_complete_token_info_returned(self, pg_conn):
|
||||
from docsgpt.parser.connectors.confluence.auth import (
|
||||
ConfluenceAuth,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.connector_sessions import (
|
||||
ConnectorSessionsRepository,
|
||||
)
|
||||
|
||||
repo = ConnectorSessionsRepository(pg_conn)
|
||||
repo.upsert("u", "confluence", status="authorized")
|
||||
session = repo.get_by_user_provider("u", "confluence")
|
||||
repo.update(
|
||||
str(session["id"]),
|
||||
{
|
||||
"session_token": "tok-good",
|
||||
"token_info": {
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt",
|
||||
"cloud_id": "cid-1",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
auth = ConfluenceAuth.__new__(ConfluenceAuth)
|
||||
with _patch_db(pg_conn):
|
||||
got = auth.get_token_info_from_session("tok-good")
|
||||
assert got["access_token"] == "at"
|
||||
assert got["cloud_id"] == "cid-1"
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from tests.parser.connectors.token_patch import patch_tokens
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -25,11 +26,12 @@ def _make_loader(token_info=None):
|
||||
|
||||
with patch("docsgpt.parser.connectors.confluence.loader.ConfluenceAuth") as MockAuth:
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.get_token_info_from_session.return_value = token_info
|
||||
token_info = token_info
|
||||
MockAuth.return_value = mock_auth
|
||||
|
||||
from docsgpt.parser.connectors.confluence.loader import ConfluenceLoader
|
||||
loader = ConfluenceLoader("session_tok")
|
||||
with patch_tokens(token_info):
|
||||
loader = ConfluenceLoader("session_tok")
|
||||
|
||||
loader.auth = mock_auth
|
||||
return loader
|
||||
@@ -69,7 +71,7 @@ class TestConfluenceLoaderInit:
|
||||
def test_init_sets_attributes(self, loader):
|
||||
assert loader.session_token == "session_tok"
|
||||
assert loader.access_token == "test_at"
|
||||
assert loader.refresh_token == "test_rt"
|
||||
assert loader.connection_id == "conn-1"
|
||||
assert loader.cloud_id == "test_cloud"
|
||||
assert loader.next_page_token is None
|
||||
assert "test_cloud" in loader.base_url
|
||||
@@ -834,18 +836,16 @@ class TestRetryOnAuthFailure:
|
||||
# Second call succeeds
|
||||
return _mock_response({"results": [], "_links": {}})
|
||||
|
||||
loader.auth.refresh_access_token = MagicMock(return_value={
|
||||
"access_token": "new_at",
|
||||
"refresh_token": "new_rt",
|
||||
})
|
||||
loader._persist_refreshed_tokens = MagicMock()
|
||||
|
||||
with patch("requests.get", side_effect=flaky_request):
|
||||
with patch(
|
||||
"docsgpt.connectors.service.get_valid_token_info",
|
||||
return_value={"access_token": "new_at", "cloud_id": "test_cloud"},
|
||||
) as tokens, patch("requests.get", side_effect=flaky_request):
|
||||
loader.load_data({})
|
||||
|
||||
assert loader.auth.refresh_access_token.called
|
||||
# The rejected token goes to the service, which refreshes under the
|
||||
# connection's row lock and stores the rotated refresh token.
|
||||
tokens.assert_called_once_with("conn-1", rejected_access_token="test_at")
|
||||
assert loader.access_token == "new_at"
|
||||
assert loader._persist_refreshed_tokens.called
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_raises_non_auth_http_error(self, loader):
|
||||
@@ -867,11 +867,9 @@ class TestRetryOnAuthFailure:
|
||||
err = requests.exceptions.HTTPError(response=resp)
|
||||
err.response = resp
|
||||
|
||||
loader.auth.refresh_access_token = MagicMock(
|
||||
side_effect=Exception("refresh failed")
|
||||
)
|
||||
|
||||
with patch("requests.get", side_effect=err):
|
||||
with patch(
|
||||
"docsgpt.connectors.service.get_valid_token_info", side_effect=Exception("refresh failed"),
|
||||
), patch("requests.get", side_effect=err):
|
||||
with pytest.raises(ValueError, match="Authentication failed"):
|
||||
loader.load_data({})
|
||||
|
||||
@@ -890,29 +888,12 @@ class TestRetryOnAuthFailure:
|
||||
raise err
|
||||
return _mock_response({"results": [], "_links": {}})
|
||||
|
||||
loader.auth.refresh_access_token = MagicMock(return_value={
|
||||
"access_token": "new_at",
|
||||
"refresh_token": "new_rt",
|
||||
})
|
||||
loader._persist_refreshed_tokens = MagicMock()
|
||||
|
||||
with patch("requests.get", side_effect=flaky):
|
||||
with patch(
|
||||
"docsgpt.connectors.service.get_valid_token_info",
|
||||
return_value={"access_token": "new_at", "cloud_id": "test_cloud"},
|
||||
) as tokens, patch("requests.get", side_effect=flaky):
|
||||
loader.load_data({})
|
||||
|
||||
assert loader.auth.refresh_access_token.called
|
||||
assert tokens.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _persist_refreshed_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistRefreshedTokens:
|
||||
pass
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_logs_warning_on_failure(self, loader):
|
||||
loader.auth.sanitize_token_info = MagicMock(side_effect=Exception("db error"))
|
||||
|
||||
# Should not raise, just log a warning
|
||||
loader._persist_refreshed_tokens({"access_token": "at"})
|
||||
@@ -51,8 +51,7 @@ def _patches(session_return: Optional[Dict[str, Any]]):
|
||||
)
|
||||
return (
|
||||
patch(
|
||||
"docsgpt.storage.db.repositories.connector_sessions."
|
||||
"ConnectorSessionsRepository",
|
||||
"docsgpt.connectors.service.ConnectorSessionsRepository",
|
||||
fake_repo_cls,
|
||||
),
|
||||
patch(
|
||||
@@ -88,57 +87,18 @@ class TestSessionTokenFingerprint:
|
||||
assert session_token_fingerprint("a") != session_token_fingerprint("b")
|
||||
|
||||
|
||||
class TestConfluenceAuthDoesNotLeakToken:
|
||||
class TestConnectionLookupDoesNotLeakToken:
|
||||
"""A session token that names no connection is reported by fingerprint only."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_session_does_not_interpolate_token(self) -> None:
|
||||
from docsgpt.parser.connectors.confluence.auth import ConfluenceAuth
|
||||
from docsgpt.connectors import service
|
||||
|
||||
auth = ConfluenceAuth.__new__(ConfluenceAuth)
|
||||
repo_patch, ctx_patch = _patches(None)
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
auth.get_token_info_from_session(SECRET_TOKEN)
|
||||
service.connection_id_for_session_token(SECRET_TOKEN)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert SECRET_TOKEN not in message
|
||||
assert session_token_fingerprint(SECRET_TOKEN) in message
|
||||
|
||||
|
||||
class TestGoogleDriveAuthDoesNotLeakToken:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_session_does_not_interpolate_token(self) -> None:
|
||||
from docsgpt.parser.connectors.google_drive.auth import GoogleDriveAuth
|
||||
|
||||
auth = GoogleDriveAuth.__new__(GoogleDriveAuth)
|
||||
repo_patch, ctx_patch = _patches(None)
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
auth.get_token_info_from_session(SECRET_TOKEN)
|
||||
|
||||
# The Google Drive module wraps the inner ValueError in a broad
|
||||
# ``except Exception as e: raise ValueError(... {str(e)})`` block,
|
||||
# so the outer message still carries the fingerprint from the
|
||||
# inner raise but must never carry the raw token.
|
||||
message = str(excinfo.value)
|
||||
assert SECRET_TOKEN not in message
|
||||
assert session_token_fingerprint(SECRET_TOKEN) in message
|
||||
|
||||
|
||||
class TestSharePointAuthDoesNotLeakToken:
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_session_does_not_interpolate_token(self) -> None:
|
||||
from docsgpt.parser.connectors.share_point.auth import SharePointAuth
|
||||
|
||||
auth = SharePointAuth.__new__(SharePointAuth)
|
||||
repo_patch, ctx_patch = _patches(None)
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
auth.get_token_info_from_session(SECRET_TOKEN)
|
||||
|
||||
# SharePoint also wraps the inner ValueError. Same invariants.
|
||||
message = str(excinfo.value)
|
||||
assert SECRET_TOKEN not in message
|
||||
assert session_token_fingerprint(SECRET_TOKEN) in message
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for ConnectorCreator factory class."""
|
||||
|
||||
from tests.parser.connectors.token_patch import patch_tokens
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -74,7 +75,7 @@ class TestConnectorCreator:
|
||||
def test_create_connector_google_drive(self):
|
||||
with patch("docsgpt.parser.connectors.google_drive.loader.GoogleDriveAuth") as MockAuth:
|
||||
mock_auth_instance = MagicMock()
|
||||
mock_auth_instance.get_token_info_from_session.return_value = {
|
||||
token_info = {
|
||||
"access_token": "at", "refresh_token": "rt"
|
||||
}
|
||||
mock_creds = MagicMock()
|
||||
@@ -84,7 +85,8 @@ class TestConnectorCreator:
|
||||
mock_auth_instance.build_drive_service.return_value = MagicMock()
|
||||
MockAuth.return_value = mock_auth_instance
|
||||
|
||||
loader = self.ConnectorCreator.create_connector("google_drive", "session_tok")
|
||||
with patch_tokens(token_info):
|
||||
loader = self.ConnectorCreator.create_connector("google_drive", "session_tok")
|
||||
from docsgpt.parser.connectors.google_drive.loader import GoogleDriveLoader
|
||||
assert isinstance(loader, GoogleDriveLoader)
|
||||
|
||||
@@ -92,11 +94,12 @@ class TestConnectorCreator:
|
||||
def test_create_connector_share_point(self):
|
||||
with patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth") as MockAuth:
|
||||
mock_auth_instance = MagicMock()
|
||||
mock_auth_instance.get_token_info_from_session.return_value = {
|
||||
token_info = {
|
||||
"access_token": "at", "refresh_token": "rt"
|
||||
}
|
||||
MockAuth.return_value = mock_auth_instance
|
||||
|
||||
loader = self.ConnectorCreator.create_connector("share_point", "session_tok")
|
||||
with patch_tokens(token_info):
|
||||
loader = self.ConnectorCreator.create_connector("share_point", "session_tok")
|
||||
from docsgpt.parser.connectors.share_point.loader import SharePointLoader
|
||||
assert isinstance(loader, SharePointLoader)
|
||||
@@ -364,71 +364,6 @@ class _FakeReadonlyCtx:
|
||||
return False
|
||||
|
||||
|
||||
class TestGetTokenInfoFromSession:
|
||||
|
||||
def _patches(self, session_return):
|
||||
fake_repo_cls = type(
|
||||
"FakeRepo",
|
||||
(_FakeRepo,),
|
||||
{"_session": session_return},
|
||||
)
|
||||
return (
|
||||
patch(
|
||||
"docsgpt.storage.db.repositories.connector_sessions.ConnectorSessionsRepository",
|
||||
fake_repo_cls,
|
||||
),
|
||||
patch(
|
||||
"docsgpt.storage.db.session.db_readonly",
|
||||
lambda: _FakeReadonlyCtx(),
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_valid_session(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({
|
||||
"session_token": "st",
|
||||
"token_info": {"access_token": "at", "refresh_token": "rt"},
|
||||
})
|
||||
with repo_patch, ctx_patch:
|
||||
result = auth.get_token_info_from_session("st")
|
||||
assert result["access_token"] == "at"
|
||||
assert result["token_uri"] == "https://oauth2.googleapis.com/token"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_session_not_found_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches(None)
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve Google Drive token"):
|
||||
auth.get_token_info_from_session("bad_token")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_session_missing_token_info_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({"session_token": "st"})
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve Google Drive token"):
|
||||
auth.get_token_info_from_session("st")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_required_fields_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({
|
||||
"session_token": "st",
|
||||
"token_info": {"access_token": "at"},
|
||||
})
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve Google Drive token"):
|
||||
auth.get_token_info_from_session("st")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_empty_token_info_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({
|
||||
"session_token": "st",
|
||||
"token_info": None,
|
||||
})
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve Google Drive token"):
|
||||
auth.get_token_info_from_session("st")
|
||||
|
||||
|
||||
class TestValidateCredentials:
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for GoogleDriveLoader."""
|
||||
|
||||
from tests.parser.connectors.token_patch import patch_tokens
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -11,7 +12,7 @@ def _make_loader(service=None):
|
||||
"""Create a GoogleDriveLoader with mocked dependencies."""
|
||||
with patch("docsgpt.parser.connectors.google_drive.loader.GoogleDriveAuth") as MockAuth:
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.get_token_info_from_session.return_value = {
|
||||
token_info = {
|
||||
"access_token": "at",
|
||||
"refresh_token": "rt",
|
||||
}
|
||||
@@ -24,7 +25,8 @@ def _make_loader(service=None):
|
||||
MockAuth.return_value = mock_auth
|
||||
|
||||
from docsgpt.parser.connectors.google_drive.loader import GoogleDriveLoader
|
||||
loader = GoogleDriveLoader("session_tok")
|
||||
with patch_tokens(token_info):
|
||||
loader = GoogleDriveLoader("session_tok")
|
||||
return loader
|
||||
|
||||
|
||||
@@ -51,7 +53,7 @@ class TestGoogleDriveLoaderInit:
|
||||
def test_init_service_failure_sets_none(self):
|
||||
with patch("docsgpt.parser.connectors.google_drive.loader.GoogleDriveAuth") as MockAuth:
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.get_token_info_from_session.return_value = {
|
||||
token_info = {
|
||||
"access_token": "at", "refresh_token": "rt"
|
||||
}
|
||||
mock_creds = MagicMock()
|
||||
@@ -61,7 +63,8 @@ class TestGoogleDriveLoaderInit:
|
||||
MockAuth.return_value = mock_auth
|
||||
|
||||
from docsgpt.parser.connectors.google_drive.loader import GoogleDriveLoader
|
||||
loader = GoogleDriveLoader("st")
|
||||
with patch_tokens(token_info):
|
||||
loader = GoogleDriveLoader("st")
|
||||
assert loader.service is None
|
||||
|
||||
|
||||
|
||||
@@ -285,63 +285,3 @@ class _FakeReadonlyCtx:
|
||||
return False
|
||||
|
||||
|
||||
class TestGetTokenInfoFromSession:
|
||||
|
||||
def _patches(self, session_return):
|
||||
fake_repo_cls = type(
|
||||
"FakeRepo",
|
||||
(_FakeRepo,),
|
||||
{"_session": session_return},
|
||||
)
|
||||
return (
|
||||
patch(
|
||||
"docsgpt.storage.db.repositories.connector_sessions.ConnectorSessionsRepository",
|
||||
fake_repo_cls,
|
||||
),
|
||||
patch(
|
||||
"docsgpt.storage.db.session.db_readonly",
|
||||
lambda: _FakeReadonlyCtx(),
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_valid_session(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({
|
||||
"session_token": "st",
|
||||
"token_info": {"access_token": "at", "refresh_token": "rt"},
|
||||
})
|
||||
with repo_patch, ctx_patch:
|
||||
result = auth.get_token_info_from_session("st")
|
||||
assert result["access_token"] == "at"
|
||||
assert "token_uri" in result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_session_not_found_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches(None)
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve SharePoint token"):
|
||||
auth.get_token_info_from_session("bad")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_token_info_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({"session_token": "st"})
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve SharePoint token"):
|
||||
auth.get_token_info_from_session("st")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_empty_token_info_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({"session_token": "st", "token_info": None})
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve SharePoint token"):
|
||||
auth.get_token_info_from_session("st")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_missing_required_fields_raises(self, auth, mock_settings):
|
||||
repo_patch, ctx_patch = self._patches({
|
||||
"session_token": "st",
|
||||
"token_info": {"access_token": "at"},
|
||||
})
|
||||
with repo_patch, ctx_patch:
|
||||
with pytest.raises(ValueError, match="Failed to retrieve SharePoint token"):
|
||||
auth.get_token_info_from_session("st")
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for SharePointLoader."""
|
||||
|
||||
import os
|
||||
from tests.parser.connectors.token_patch import patch_tokens
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -13,7 +14,7 @@ def _make_loader(access_token="at", refresh_token="rt", allows_shared=False):
|
||||
"""Create a SharePointLoader with mocked dependencies."""
|
||||
with patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth") as MockAuth:
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.get_token_info_from_session.return_value = {
|
||||
token_info = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"allows_shared_content": allows_shared,
|
||||
@@ -22,7 +23,8 @@ def _make_loader(access_token="at", refresh_token="rt", allows_shared=False):
|
||||
MockAuth.return_value = mock_auth
|
||||
|
||||
from docsgpt.parser.connectors.share_point.loader import SharePointLoader
|
||||
loader = SharePointLoader("session_tok")
|
||||
with patch_tokens(token_info):
|
||||
loader = SharePointLoader("session_tok")
|
||||
return loader
|
||||
|
||||
|
||||
@@ -42,7 +44,7 @@ class TestSharePointLoaderInit:
|
||||
def test_init_sets_attributes(self, loader):
|
||||
assert loader.session_token == "session_tok"
|
||||
assert loader.access_token == "at"
|
||||
assert loader.refresh_token == "rt"
|
||||
assert loader.connection_id == "conn-1"
|
||||
assert loader.allows_shared_content is False
|
||||
assert loader.next_page_token is None
|
||||
|
||||
@@ -50,7 +52,7 @@ class TestSharePointLoaderInit:
|
||||
def test_no_access_token_raises(self):
|
||||
with patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth") as MockAuth:
|
||||
mock_auth = MagicMock()
|
||||
mock_auth.get_token_info_from_session.return_value = {
|
||||
token_info = {
|
||||
"access_token": None,
|
||||
"refresh_token": "rt",
|
||||
}
|
||||
@@ -58,7 +60,8 @@ class TestSharePointLoaderInit:
|
||||
|
||||
from docsgpt.parser.connectors.share_point.loader import SharePointLoader
|
||||
with pytest.raises(ValueError, match="No access token"):
|
||||
SharePointLoader("st")
|
||||
with patch_tokens(token_info):
|
||||
SharePointLoader("st")
|
||||
|
||||
|
||||
class TestGetHeaders:
|
||||
@@ -86,16 +89,19 @@ class TestEnsureValidToken:
|
||||
@pytest.mark.unit
|
||||
def test_expired_token_refreshes(self, loader):
|
||||
loader.auth.is_token_expired.return_value = True
|
||||
loader.auth.refresh_access_token.return_value = {"access_token": "new_at"}
|
||||
loader._ensure_valid_token()
|
||||
with patch(
|
||||
"docsgpt.connectors.service.get_valid_token_info", return_value={"access_token": "new_at"},
|
||||
) as tokens:
|
||||
loader._ensure_valid_token()
|
||||
assert loader.access_token == "new_at"
|
||||
tokens.assert_called_once_with("conn-1")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_refresh_failure_raises(self, loader):
|
||||
loader.auth.is_token_expired.return_value = True
|
||||
loader.auth.refresh_access_token.side_effect = Exception("fail")
|
||||
with pytest.raises(ValueError, match="Failed to refresh"):
|
||||
loader._ensure_valid_token()
|
||||
with patch("docsgpt.connectors.service.get_valid_token_info", side_effect=Exception("fail")):
|
||||
with pytest.raises(ValueError, match="Failed to refresh"):
|
||||
loader._ensure_valid_token()
|
||||
|
||||
|
||||
class TestGetItemUrl:
|
||||
@@ -305,7 +311,7 @@ class TestLoadFileById:
|
||||
return mock_resp_ok
|
||||
|
||||
loader._process_file = MagicMock(return_value=Document(text="", doc_id="f1", extra_info={}))
|
||||
loader.auth.refresh_access_token.return_value = {"access_token": "new_at"}
|
||||
loader._refresh_rejected_token = MagicMock(return_value={"access_token": "new_at"})
|
||||
|
||||
with patch("docsgpt.parser.connectors.share_point.loader.requests.get", side_effect=get_side_effect):
|
||||
doc = loader._load_file_by_id("f1")
|
||||
@@ -481,7 +487,7 @@ class TestDownloadFileContent:
|
||||
raise http_error
|
||||
return mock_resp_ok
|
||||
|
||||
loader.auth.refresh_access_token.return_value = {"access_token": "new_at"}
|
||||
loader._refresh_rejected_token = MagicMock(return_value={"access_token": "new_at"})
|
||||
|
||||
with patch("docsgpt.parser.connectors.share_point.loader.requests.get", side_effect=get_side_effect):
|
||||
content = loader._download_file_content("f1")
|
||||
@@ -1004,7 +1010,7 @@ class TestRetryOnAuthFailureDecorator:
|
||||
raise http_error
|
||||
return mock_resp_ok
|
||||
|
||||
loader.auth.refresh_access_token.return_value = {"access_token": "new_at"}
|
||||
loader._refresh_rejected_token = MagicMock(return_value={"access_token": "new_at"})
|
||||
|
||||
with patch("docsgpt.parser.connectors.share_point.loader.requests.get", side_effect=get_side_effect):
|
||||
content = loader._download_file_content("f1")
|
||||
@@ -1018,7 +1024,7 @@ class TestRetryOnAuthFailureDecorator:
|
||||
mock_resp_403.status_code = 403
|
||||
http_error = real_requests.exceptions.HTTPError(response=mock_resp_403)
|
||||
|
||||
loader.auth.refresh_access_token.side_effect = Exception("refresh fail")
|
||||
loader._refresh_rejected_token = MagicMock(side_effect=Exception("refresh fail"))
|
||||
|
||||
with patch("docsgpt.parser.connectors.share_point.loader.requests.get", side_effect=http_error):
|
||||
with pytest.raises(ValueError, match="could not be refreshed"):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Stand-in for the connection service in connector loader tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patch_tokens(token_info: dict, connection_id: str = "conn-1"):
|
||||
"""Make every loader resolve ``connection_id`` and read ``token_info``.
|
||||
|
||||
Yields the ``get_valid_token_info`` mock so a test can assert on or
|
||||
change what a refresh returns.
|
||||
"""
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch("docsgpt.connectors.service.connection_id_for_session_token", return_value=connection_id)
|
||||
)
|
||||
tokens = stack.enter_context(
|
||||
patch("docsgpt.connectors.service.get_valid_token_info", return_value=token_info)
|
||||
)
|
||||
yield tokens
|
||||
@@ -66,7 +66,8 @@ class TestSharePointLoaderLoadFileById:
|
||||
"""Test _load_file_by_id method."""
|
||||
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.requests.get")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
|
||||
@patch("docsgpt.connectors.service.connection_id_for_session_token", new=lambda token: "conn-1")
|
||||
@patch("docsgpt.connectors.service.get_valid_token_info")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
|
||||
def test_load_file_by_id_includes_size_in_select(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
|
||||
@@ -97,7 +98,8 @@ class TestSharePointLoaderLoadFileById:
|
||||
assert "size" in params["$select"]
|
||||
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.requests.get")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
|
||||
@patch("docsgpt.connectors.service.connection_id_for_session_token", new=lambda token: "conn-1")
|
||||
@patch("docsgpt.connectors.service.get_valid_token_info")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
|
||||
def test_load_file_by_id_returns_document_with_size(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
|
||||
@@ -134,7 +136,8 @@ class TestSharePointLoaderListItems:
|
||||
"""Test _list_items_in_parent method."""
|
||||
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.requests.get")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
|
||||
@patch("docsgpt.connectors.service.connection_id_for_session_token", new=lambda token: "conn-1")
|
||||
@patch("docsgpt.connectors.service.get_valid_token_info")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
|
||||
def test_list_items_includes_size_in_select(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
|
||||
@@ -169,7 +172,8 @@ class TestSharePointLoaderListItems:
|
||||
assert "size" in params["$select"]
|
||||
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.requests.get")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.get_token_info_from_session")
|
||||
@patch("docsgpt.connectors.service.connection_id_for_session_token", new=lambda token: "conn-1")
|
||||
@patch("docsgpt.connectors.service.get_valid_token_info")
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointAuth.__init__", return_value=None)
|
||||
@patch("docsgpt.parser.connectors.share_point.loader.SharePointLoader._ensure_valid_token")
|
||||
def test_list_items_folders_include_size(self, mock_ensure_token, mock_auth_init, mock_get_token, mock_get):
|
||||
|
||||
Reference in new issue
Block a user