diff --git a/nodes/src/nodes/tool_google_workspace/drive/IInstance.py b/nodes/src/nodes/tool_google_workspace/drive/IInstance.py index 5267e4933..edc7568e4 100644 --- a/nodes/src/nodes/tool_google_workspace/drive/IInstance.py +++ b/nodes/src/nodes/tool_google_workspace/drive/IInstance.py @@ -156,13 +156,13 @@ def _files(self): description=( 'Check the Google Drive connection and verify that the granted OAuth scopes cover the ' "node's configured access tier. Call this when a Drive operation fails with a scope or " - 'permission error. Returns connection_ok: true when the required scopes are present.' + 'permission error. Returns connection_ok: true when the API probe succeeds and the required scopes are present.' ), input_schema={'type': 'object', 'properties': {}, 'required': []}, ) def check_connection(self, args: dict) -> dict: """Check Drive connection status and whether granted OAuth scopes cover the access tier. Read-only.""" - return self._check_connection_impl() + return self._check_connection_impl(probe=lambda s: execute(s.about().get(fields='user'))) # ======================================================================= # FILES — read diff --git a/nodes/src/nodes/tool_google_workspace/gmail/IInstance.py b/nodes/src/nodes/tool_google_workspace/gmail/IInstance.py index 2b7e27534..3e8947708 100644 --- a/nodes/src/nodes/tool_google_workspace/gmail/IInstance.py +++ b/nodes/src/nodes/tool_google_workspace/gmail/IInstance.py @@ -140,13 +140,19 @@ def _id_list(args: dict, key: str, op: str) -> list[str]: "Check the Gmail connection status and verify that the OAuth token's granted scopes " "cover the node's configured access tier. Call this when a Gmail operation fails with " 'a scope or permission error, or before attempting settings-level operations for the ' - 'first time. Returns connection_ok: true when all required scopes are present.' + 'first time. Returns connection_ok: true when the API probe succeeds and all required scopes are present.' ), input_schema={'type': 'object', 'properties': {}, 'required': []}, ) def check_connection(self, args: dict) -> dict: """Check Gmail connection status and whether granted OAuth scopes cover the configured access tier. Read-only.""" - return self._check_connection_impl() + + def probe(s): + if _GMAIL_SETTINGS_SCOPE in self._access().scopes: + return execute(s.users().settings().getPop(userId=USER_ID)) + return execute(s.users().getProfile(userId=USER_ID)) + + return self._check_connection_impl(probe=probe) # ======================================================================= # MESSAGES — read diff --git a/nodes/test/tool_google_workspace/drive/test_drive.py b/nodes/test/tool_google_workspace/drive/test_drive.py index 9a5dc5f64..7d665b1be 100644 --- a/nodes/test/tool_google_workspace/drive/test_drive.py +++ b/nodes/test/tool_google_workspace/drive/test_drive.py @@ -186,6 +186,9 @@ def drives(self): def changes(self): return _Node(self, 'changes') + def about(self): + return _Node(self, 'about') + def call_for(self, op): """Return the kwargs of the FIRST recorded call to terminal method ``op``.""" return next((kw for n, kw in self.calls if n == op), None) @@ -729,6 +732,14 @@ def test_check_connection_reports_ok(): assert out['connection_ok'] is True assert out['access'] == 'write' assert any('drive' in s for s in out['requiredScopes']) + assert inst.IGlobal.service.call_for('get')['fields'] == 'user' + + +def test_check_connection_reports_drive_api_probe_failure(): + inst = _make(results={'get': RuntimeError('Drive API disabled')}) + out = inst.check_connection({}) + assert out['connection_ok'] is False + assert 'Drive API disabled' in out['error'] # --------------------------------------------------------------------------- diff --git a/nodes/test/tool_google_workspace/gmail/test_gmail.py b/nodes/test/tool_google_workspace/gmail/test_gmail.py index 3d98257d3..edb6bc9b6 100644 --- a/nodes/test/tool_google_workspace/gmail/test_gmail.py +++ b/nodes/test/tool_google_workspace/gmail/test_gmail.py @@ -569,9 +569,9 @@ def test_thread_modify_requires_labels(): # --------------------------------------------------------------------------- -def _inst_with_token(access_tier: str, token: dict | None) -> 'IInstance.IInstance': +def _inst_with_token(access_tier: str, token: dict | None, results: dict | None = None) -> 'IInstance.IInstance': """Return an IInstance whose IGlobal.glb simulates a persisted user token.""" - inst = make_inst(access_tier) + inst = make_inst(access_tier, results=results) token_json = json.dumps(token) if token is not None else '' inst.IGlobal.glb = types.SimpleNamespace(logicalType='tool_gmail', connConfig={}) inst.IGlobal._token_cfg = {'authType': 'user', 'userToken': token_json} @@ -597,6 +597,7 @@ def test_check_connection_missing_scope(monkeypatch): _patch_config(monkeypatch, inst) result = inst.check_connection({}) assert not result['connection_ok'] + assert inst.IGlobal.service.call_for('getPop')['userId'] == 'me' assert any('gmail.settings.basic' in s for s in result['missingScopes']) assert result['authType'] == 'user' @@ -609,10 +610,26 @@ def test_check_connection_ok_with_full_scope(monkeypatch): _patch_config(monkeypatch, inst) result = inst.check_connection({}) assert result['connection_ok'] + assert inst.IGlobal.service.call_for('getPop')['userId'] == 'me' assert result.get('missingScopes', []) == [] assert result['authType'] == 'user' +def test_check_connection_reports_gmail_api_probe_failure(monkeypatch): + """check_connection reports disabled/unreachable Gmail API as not OK.""" + mocks = Path(__file__).resolve().parents[2] / 'mocks' + monkeypatch.syspath_prepend(str(mocks)) + inst = _inst_with_token( + 'settings', + {'access_token': 'tok', 'scope': 'https://mail.google.com/'}, + results={'getPop': RuntimeError('Gmail API disabled')}, + ) + _patch_config(monkeypatch, inst) + result = inst.check_connection({}) + assert result['connection_ok'] is False + assert 'Gmail API disabled' in result['error'] + + def test_check_connection_readonly_satisfied_by_modify(monkeypatch): """A token granted gmail.modify covers the readonly tier (scope implication).""" mocks = Path(__file__).resolve().parents[2] / 'mocks'