| 1 | # Copyright 2026 Google LLC |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # |
| 7 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | # |
| 9 | # Unless required by applicable law or agreed to in writing, software |
| 10 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | # See the License for the specific language governing permissions and |
| 13 | # limitations under the License. |
| 14 | |
| 15 | """Tests for the Application Default Credentials (ADC) auth provider.""" |
| 16 | |
| 17 | from unittest.mock import MagicMock |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | from colab_cli.auth import AuthProvider, get_credentials |
| 22 | |
| 23 | |
| 24 | def test_get_credentials_adc_success(mocker): |
| 25 | """`--auth=adc` should delegate to google.auth.default() and wrap the |
| 26 | resulting credentials in an AuthorizedSession.""" |
| 27 | mock_creds = MagicMock() |
| 28 | # Default ADC user creds don't need (or support) re-scoping; the scopes |
| 29 | # are fixed at `gcloud auth application-default login` time. |
| 30 | mock_creds.requires_scopes = False |
| 31 | mock_default = mocker.patch( |
| 32 | "google.auth.default", return_value=(mock_creds, "some-project-id") |
| 33 | ) |
| 34 | mock_session_cls = mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 35 | |
| 36 | res = get_credentials(provider=AuthProvider.ADC) |
| 37 | |
| 38 | mock_default.assert_called_once() |
| 39 | mock_session_cls.assert_called_once_with(mock_creds) |
| 40 | assert res == mock_session_cls.return_value |
| 41 | |
| 42 | |
| 43 | def test_get_credentials_adc_default_error_propagates(mocker): |
| 44 | """If google.auth.default() raises (e.g. no ADC configured), the error |
| 45 | should propagate to the caller so they can run `gcloud auth |
| 46 | application-default login`.""" |
| 47 | from google.auth.exceptions import DefaultCredentialsError |
| 48 | |
| 49 | mocker.patch( |
| 50 | "google.auth.default", |
| 51 | side_effect=DefaultCredentialsError("No ADC found"), |
| 52 | ) |
| 53 | mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 54 | |
| 55 | with pytest.raises(DefaultCredentialsError): |
| 56 | get_credentials(provider=AuthProvider.ADC) |
| 57 | |
| 58 | |
| 59 | def test_get_credentials_adc_does_not_invoke_other_providers(mocker): |
| 60 | """ADC path must not kick off the InstalledAppFlow.""" |
| 61 | mock_creds = MagicMock() |
| 62 | mock_creds.requires_scopes = False |
| 63 | mocker.patch("google.auth.default", return_value=(mock_creds, None)) |
| 64 | mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 65 | |
| 66 | mock_flow = mocker.patch("colab_cli.auth.InstalledAppFlow") |
| 67 | |
| 68 | get_credentials(provider=AuthProvider.ADC) |
| 69 | |
| 70 | mock_flow.from_client_config.assert_not_called() |
| 71 | |
| 72 | |
| 73 | def test_get_credentials_adc_requests_required_scopes(mocker): |
| 74 | """ADC must request required scopes via google.auth.default().""" |
| 75 | mock_creds = MagicMock() |
| 76 | # Pretend creds don't need re-scoping (e.g., user creds from gcloud). |
| 77 | mock_creds.requires_scopes = False |
| 78 | mock_default = mocker.patch( |
| 79 | "google.auth.default", return_value=(mock_creds, "proj") |
| 80 | ) |
| 81 | mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 82 | |
| 83 | get_credentials(provider=AuthProvider.ADC) |
| 84 | |
| 85 | scopes = mock_default.call_args.kwargs.get("scopes") |
| 86 | assert scopes is not None, "google.auth.default() must be called with scopes=" |
| 87 | assert "https://www.googleapis.com/auth/colaboratory" in scopes |
| 88 | assert "https://www.googleapis.com/auth/cloud-platform" in scopes |
| 89 | assert "https://www.googleapis.com/auth/userinfo.email" in scopes |
| 90 | |
| 91 | |
| 92 | def test_get_credentials_adc_reapplies_scopes_for_scopable_creds(mocker): |
| 93 | """For credential subclasses that support `with_scopes` (service accounts, |
| 94 | GCE/GKE, etc.), we must call it so the scopes stick even if |
| 95 | google.auth.default() ignored the kwarg. |
| 96 | """ |
| 97 | rescoped = MagicMock(name="rescoped_creds") |
| 98 | mock_creds = MagicMock() |
| 99 | mock_creds.requires_scopes = True |
| 100 | mock_creds.with_scopes.return_value = rescoped |
| 101 | mocker.patch("google.auth.default", return_value=(mock_creds, "proj")) |
| 102 | mock_session_cls = mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 103 | |
| 104 | get_credentials(provider=AuthProvider.ADC) |
| 105 | |
| 106 | mock_creds.with_scopes.assert_called_once() |
| 107 | applied_scopes = mock_creds.with_scopes.call_args.args[0] |
| 108 | assert "https://www.googleapis.com/auth/cloud-platform" in applied_scopes |
| 109 | # The session is built from the *rescoped* creds, not the original. |
| 110 | mock_session_cls.assert_called_once_with(rescoped) |
| 111 | |
| 112 | |
| 113 | def test_get_credentials_adc_tolerates_with_scopes_failure(mocker): |
| 114 | """User creds (from `gcloud auth application-default login`) raise |
| 115 | NotImplementedError on with_scopes. We must fall back gracefully.""" |
| 116 | mock_creds = MagicMock() |
| 117 | mock_creds.requires_scopes = True |
| 118 | mock_creds.with_scopes.side_effect = NotImplementedError("user creds") |
| 119 | mocker.patch("google.auth.default", return_value=(mock_creds, "proj")) |
| 120 | mock_session_cls = mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 121 | |
| 122 | # Should not raise. |
| 123 | get_credentials(provider=AuthProvider.ADC) |
| 124 | |
| 125 | # Falls back to using the original (un-rescoped) creds. |
| 126 | mock_session_cls.assert_called_once_with(mock_creds) |
| 127 | |
| 128 | |
| 129 | def test_get_credentials_adc_suppresses_quota_project_warning(mocker, recwarn): |
| 130 | """`google-auth` emits a UserWarning ("Your application has authenticated |
| 131 | using end user credentials from Google Cloud SDK without a quota project. |
| 132 | You might receive a 'quota exceeded' or 'API not enabled' error.") whenever |
| 133 | ADC user credentials lack a quota project. |
| 134 | |
| 135 | For this CLI the warning is strictly false: we send |
| 136 | `X-Goog-User-Project: 1014160490159` (Colab's project) ourselves |
| 137 | (AGENTS.md item 18), so google-auth's heuristic does not apply. Suppress |
| 138 | it locally around the `google.auth.default()` call so it never reaches |
| 139 | the user's terminal on every `colab` invocation. |
| 140 | """ |
| 141 | import warnings |
| 142 | |
| 143 | mock_creds = MagicMock() |
| 144 | mock_creds.requires_scopes = False |
| 145 | |
| 146 | # Simulate google-auth emitting the noisy warning during default(), as |
| 147 | # google.auth._default does for `_CLOUD_SDK_CREDENTIALS_WARNING`. |
| 148 | def fake_default(*args, **kwargs): |
| 149 | warnings.warn( |
| 150 | "Your application has authenticated using end user credentials " |
| 151 | "from Google Cloud SDK without a quota project. You might receive " |
| 152 | 'a "quota exceeded" or "API not enabled" error. See the following ' |
| 153 | "page for troubleshooting: " |
| 154 | "https://cloud.google.com/docs/authentication/adc-troubleshooting/user-creds. ", |
| 155 | UserWarning, |
| 156 | ) |
| 157 | return mock_creds, "proj" |
| 158 | |
| 159 | mocker.patch("google.auth.default", side_effect=fake_default) |
| 160 | mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 161 | |
| 162 | get_credentials(provider=AuthProvider.ADC) |
| 163 | |
| 164 | # The quota-project warning must be filtered out; nothing else. |
| 165 | matching = [ |
| 166 | w |
| 167 | for w in recwarn.list |
| 168 | if issubclass(w.category, UserWarning) |
| 169 | and "without a quota project" in str(w.message) |
| 170 | ] |
| 171 | assert matching == [], ( |
| 172 | "Expected the quota-project UserWarning to be suppressed, but it " |
| 173 | f"was visible: {[str(w.message) for w in matching]}" |
| 174 | ) |
| 175 | |
| 176 | |
| 177 | def test_get_credentials_adc_does_not_suppress_unrelated_warnings(mocker, recwarn): |
| 178 | """Suppression must be tightly scoped to the quota-project warning text |
| 179 | so that any future genuinely-relevant `google.auth` warning still |
| 180 | surfaces to the user.""" |
| 181 | import warnings |
| 182 | |
| 183 | mock_creds = MagicMock() |
| 184 | mock_creds.requires_scopes = False |
| 185 | |
| 186 | def fake_default(*args, **kwargs): |
| 187 | warnings.warn("some genuinely concerning new warning", UserWarning) |
| 188 | return mock_creds, "proj" |
| 189 | |
| 190 | mocker.patch("google.auth.default", side_effect=fake_default) |
| 191 | mocker.patch("colab_cli.auth.requests.AuthorizedSession") |
| 192 | |
| 193 | get_credentials(provider=AuthProvider.ADC) |
| 194 | |
| 195 | matching = [ |
| 196 | w |
| 197 | for w in recwarn.list |
| 198 | if issubclass(w.category, UserWarning) |
| 199 | and "genuinely concerning" in str(w.message) |
| 200 | ] |
| 201 | assert len(matching) == 1, ( |
| 202 | "Unrelated UserWarnings from google.auth must NOT be suppressed." |
| 203 | ) |