jac-scale 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jac_scale/abstractions/config/app_config.jac +5 -2
- jac_scale/context.jac +2 -1
- jac_scale/factories/storage_factory.jac +75 -0
- jac_scale/google_sso_provider.jac +85 -0
- jac_scale/impl/context.impl.jac +3 -0
- jac_scale/impl/serve.impl.jac +82 -234
- jac_scale/impl/user_manager.impl.jac +349 -0
- jac_scale/memory_hierarchy.jac +3 -1
- jac_scale/plugin.jac +46 -3
- jac_scale/plugin_config.jac +27 -0
- jac_scale/serve.jac +3 -12
- jac_scale/sso_provider.jac +72 -0
- jac_scale/targets/kubernetes/kubernetes_config.jac +9 -15
- jac_scale/targets/kubernetes/kubernetes_target.jac +174 -15
- jac_scale/tests/fixtures/scale-feats/components/Button.cl.jac +32 -0
- jac_scale/tests/fixtures/scale-feats/main.jac +147 -0
- jac_scale/tests/fixtures/test_api.jac +29 -0
- jac_scale/tests/fixtures/test_restspec.jac +37 -0
- jac_scale/tests/test_deploy_k8s.py +2 -1
- jac_scale/tests/test_examples.py +180 -5
- jac_scale/tests/test_hooks.py +39 -0
- jac_scale/tests/test_restspec.py +192 -0
- jac_scale/tests/test_serve.py +54 -0
- jac_scale/tests/test_sso.py +273 -284
- jac_scale/tests/test_storage.py +274 -0
- jac_scale/user_manager.jac +49 -0
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.3.dist-info}/METADATA +9 -2
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.3.dist-info}/RECORD +31 -20
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.3.dist-info}/WHEEL +1 -1
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.3.dist-info}/entry_points.txt +0 -0
- {jac_scale-0.1.1.dist-info → jac_scale-0.1.3.dist-info}/top_level.txt +0 -0
jac_scale/tests/test_sso.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Test for SSO (Single Sign-On) implementation in jac-scale."""
|
|
2
2
|
|
|
3
|
-
import contextlib
|
|
4
3
|
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
5
7
|
from dataclasses import dataclass
|
|
6
8
|
from types import TracebackType
|
|
7
9
|
from unittest.mock import AsyncMock, Mock, patch
|
|
@@ -12,7 +14,9 @@ from fastapi import Request
|
|
|
12
14
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
13
15
|
|
|
14
16
|
from jac_scale.config_loader import reset_scale_config
|
|
17
|
+
from jac_scale.google_sso_provider import GoogleSSOProvider
|
|
15
18
|
from jac_scale.serve import JacAPIServer, Operations, Platforms
|
|
19
|
+
from jac_scale.user_manager import JacScaleUserManager
|
|
16
20
|
from jaclang.runtimelib.transport import TransportResponse
|
|
17
21
|
|
|
18
22
|
|
|
@@ -62,7 +66,7 @@ class MockUserInfo:
|
|
|
62
66
|
|
|
63
67
|
|
|
64
68
|
class MockGoogleSSO:
|
|
65
|
-
"""Mock GoogleSSO for testing."""
|
|
69
|
+
"""Mock GoogleSSO for testing - implements SSOProvider interface."""
|
|
66
70
|
|
|
67
71
|
def __init__(
|
|
68
72
|
self,
|
|
@@ -78,6 +82,8 @@ class MockGoogleSSO:
|
|
|
78
82
|
# Set default callables that can be overridden
|
|
79
83
|
self.get_login_redirect = self._default_get_login_redirect
|
|
80
84
|
self.verify_and_process = self._default_verify_and_process
|
|
85
|
+
self.initiate_auth = self._default_initiate_auth
|
|
86
|
+
self.handle_callback = self._default_handle_callback
|
|
81
87
|
|
|
82
88
|
async def _default_get_login_redirect(self) -> RedirectResponse:
|
|
83
89
|
"""Mock get_login_redirect method."""
|
|
@@ -87,6 +93,19 @@ class MockGoogleSSO:
|
|
|
87
93
|
"""Mock verify_and_process method."""
|
|
88
94
|
return MockUserInfo(email="test@example.com")
|
|
89
95
|
|
|
96
|
+
async def _default_initiate_auth(self, operation: str) -> RedirectResponse:
|
|
97
|
+
"""Mock initiate_auth method (SSOProvider interface)."""
|
|
98
|
+
return RedirectResponse(url="https://accounts.google.com/oauth/authorize")
|
|
99
|
+
|
|
100
|
+
async def _default_handle_callback(self, request: Request) -> MockUserInfo:
|
|
101
|
+
"""Mock handle_callback method (SSOProvider interface) - returns MockUserInfo which acts like SSOUserInfo."""
|
|
102
|
+
# Call verify_and_process to maintain compatibility
|
|
103
|
+
return await self.verify_and_process(request)
|
|
104
|
+
|
|
105
|
+
def get_platform_name(self) -> str:
|
|
106
|
+
"""Mock get_platform_name method (SSOProvider interface)."""
|
|
107
|
+
return "google"
|
|
108
|
+
|
|
90
109
|
def __enter__(self) -> "MockGoogleSSO":
|
|
91
110
|
return self
|
|
92
111
|
|
|
@@ -108,39 +127,37 @@ class MockScaleConfig:
|
|
|
108
127
|
def get_sso_config(self) -> dict:
|
|
109
128
|
return self._sso_config
|
|
110
129
|
|
|
130
|
+
def get_jwt_config(self) -> dict:
|
|
131
|
+
return {
|
|
132
|
+
"secret": "test_secret",
|
|
133
|
+
"algorithm": "HS256",
|
|
134
|
+
"exp_delta_days": 1,
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
|
|
112
|
-
class
|
|
113
|
-
"""Test SSO functionality in
|
|
138
|
+
class TestJacScaleUserManagerSSO:
|
|
139
|
+
"""Test SSO functionality in JacScaleUserManager."""
|
|
114
140
|
|
|
115
141
|
def setup_method(self) -> None:
|
|
116
142
|
"""Setup for each test method."""
|
|
143
|
+
# Create temp directory for independent DB per test
|
|
144
|
+
self.test_dir = tempfile.mkdtemp()
|
|
145
|
+
|
|
117
146
|
# Reset config singleton to ensure fresh config
|
|
118
147
|
reset_scale_config()
|
|
119
148
|
|
|
120
|
-
# Mock the server components
|
|
121
|
-
self.mock_server_impl = Mock()
|
|
122
|
-
self.mock_user_manager = Mock()
|
|
123
|
-
self.mock_introspector = Mock()
|
|
124
|
-
self.mock_execution_manager = Mock()
|
|
125
|
-
|
|
126
|
-
# Create JacAPIServer instance with mocked config (jac.toml approach)
|
|
127
149
|
mock_config = MockScaleConfig(mock_sso_config_with_credentials())
|
|
128
|
-
with patch("jac_scale.serve.get_scale_config", return_value=mock_config):
|
|
129
|
-
self.server = JacAPIServer(
|
|
130
|
-
module_name="test_module",
|
|
131
|
-
port=8000,
|
|
132
|
-
)
|
|
133
150
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
self.
|
|
138
|
-
self.
|
|
151
|
+
with patch("jac_scale.user_manager.get_scale_config", return_value=mock_config):
|
|
152
|
+
self.user_manager = JacScaleUserManager(base_path=self.test_dir)
|
|
153
|
+
|
|
154
|
+
self.user_manager.create_user = Mock()
|
|
155
|
+
self.user_manager.get_user = Mock()
|
|
139
156
|
|
|
140
157
|
def teardown_method(self) -> None:
|
|
141
|
-
"""
|
|
142
|
-
|
|
143
|
-
|
|
158
|
+
"""Clean up temp directory."""
|
|
159
|
+
if hasattr(self, "test_dir") and os.path.exists(self.test_dir):
|
|
160
|
+
shutil.rmtree(self.test_dir)
|
|
144
161
|
|
|
145
162
|
@staticmethod
|
|
146
163
|
def _get_response_body(result: JSONResponse | TransportResponse) -> str:
|
|
@@ -179,50 +196,56 @@ class TestJacAPIServerSSO:
|
|
|
179
196
|
|
|
180
197
|
def test_get_sso_with_google_platform(self) -> None:
|
|
181
198
|
"""Test get_sso returns GoogleSSO instance for Google platform."""
|
|
182
|
-
with
|
|
183
|
-
|
|
199
|
+
# Patch GoogleSSOProvider with side_effect to create MockGoogleSSO instances
|
|
200
|
+
with patch(
|
|
201
|
+
"jac_scale.google_sso_provider.GoogleSSOProvider", side_effect=MockGoogleSSO
|
|
202
|
+
) as mock_sso:
|
|
203
|
+
sso = self.user_manager.get_sso(
|
|
204
|
+
Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
205
|
+
)
|
|
184
206
|
|
|
185
207
|
assert sso is not None
|
|
208
|
+
# Verify attributes
|
|
209
|
+
assert sso.client_id == "test_client_id"
|
|
210
|
+
assert sso.client_secret == "test_client_secret"
|
|
211
|
+
# Verify GoogleSSOProvider was called with correct parameters
|
|
186
212
|
mock_sso.assert_called_once()
|
|
187
213
|
|
|
188
214
|
def test_get_sso_with_invalid_platform(self) -> None:
|
|
189
215
|
"""Test get_sso returns None for invalid platform."""
|
|
190
|
-
sso = self.
|
|
216
|
+
sso = self.user_manager.get_sso("invalid_platform", Operations.LOGIN.value)
|
|
191
217
|
assert sso is None
|
|
192
218
|
|
|
193
219
|
def test_get_sso_with_unconfigured_platform(self) -> None:
|
|
194
220
|
"""Test get_sso returns None when platform credentials are not configured in jac.toml."""
|
|
195
221
|
reset_scale_config()
|
|
196
|
-
# Mock config without credentials (simulating empty [plugins.scale.sso] in jac.toml)
|
|
197
222
|
mock_config = MockScaleConfig(mock_sso_config_without_credentials())
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
)
|
|
203
|
-
sso = server.get_sso(Platforms.GOOGLE.value, Operations.LOGIN.value)
|
|
223
|
+
# Patch the correct location where get_scale_config is imported
|
|
224
|
+
with patch("jac_scale.user_manager.get_scale_config", return_value=mock_config):
|
|
225
|
+
# Re-init user manager to reload config
|
|
226
|
+
user_manager = JacScaleUserManager(base_path="")
|
|
227
|
+
sso = user_manager.get_sso(Platforms.GOOGLE.value, Operations.LOGIN.value)
|
|
204
228
|
assert sso is None
|
|
205
229
|
|
|
206
230
|
def test_get_sso_redirect_uri_format(self) -> None:
|
|
207
231
|
"""Test get_sso creates correct redirect URI based on jac.toml SSO host."""
|
|
208
|
-
with patch(
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
assert call_args is not None
|
|
214
|
-
assert (
|
|
215
|
-
call_args.kwargs["redirect_uri"]
|
|
216
|
-
== "http://localhost:8000/sso/google/login/callback"
|
|
232
|
+
with patch(
|
|
233
|
+
"jac_scale.google_sso_provider.GoogleSSOProvider", side_effect=MockGoogleSSO
|
|
234
|
+
):
|
|
235
|
+
sso = self.user_manager.get_sso(
|
|
236
|
+
Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
217
237
|
)
|
|
238
|
+
assert sso.redirect_uri == "http://localhost:8000/sso/google/login/callback"
|
|
218
239
|
|
|
219
240
|
@pytest.mark.asyncio
|
|
220
241
|
async def test_sso_initiate_success(self) -> None:
|
|
221
242
|
"""Test successful SSO initiation."""
|
|
222
243
|
with patch.object(
|
|
223
|
-
self.
|
|
244
|
+
self.user_manager,
|
|
245
|
+
"get_sso",
|
|
246
|
+
return_value=MockGoogleSSO("id", "secret", "uri"),
|
|
224
247
|
):
|
|
225
|
-
result = await self.
|
|
248
|
+
result = await self.user_manager.sso_initiate(
|
|
226
249
|
Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
227
250
|
)
|
|
228
251
|
|
|
@@ -232,12 +255,11 @@ class TestJacAPIServerSSO:
|
|
|
232
255
|
@pytest.mark.asyncio
|
|
233
256
|
async def test_sso_initiate_with_invalid_platform(self) -> None:
|
|
234
257
|
"""Test SSO initiation with invalid platform."""
|
|
235
|
-
result = await self.
|
|
258
|
+
result = await self.user_manager.sso_initiate(
|
|
236
259
|
"invalid_platform", Operations.LOGIN.value
|
|
237
260
|
)
|
|
238
261
|
|
|
239
262
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
240
|
-
# Extract body from JSONResponse or TransportResponse
|
|
241
263
|
body = self._get_response_body(result)
|
|
242
264
|
assert "Invalid platform" in body
|
|
243
265
|
|
|
@@ -245,9 +267,9 @@ class TestJacAPIServerSSO:
|
|
|
245
267
|
async def test_sso_initiate_with_unconfigured_platform(self) -> None:
|
|
246
268
|
"""Test SSO initiation with unconfigured platform."""
|
|
247
269
|
# Clear supported platforms
|
|
248
|
-
self.
|
|
270
|
+
self.user_manager.SUPPORTED_PLATFORMS = {}
|
|
249
271
|
|
|
250
|
-
result = await self.
|
|
272
|
+
result = await self.user_manager.sso_initiate(
|
|
251
273
|
Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
252
274
|
)
|
|
253
275
|
|
|
@@ -258,7 +280,7 @@ class TestJacAPIServerSSO:
|
|
|
258
280
|
@pytest.mark.asyncio
|
|
259
281
|
async def test_sso_initiate_with_invalid_operation(self) -> None:
|
|
260
282
|
"""Test SSO initiation with invalid operation."""
|
|
261
|
-
result = await self.
|
|
283
|
+
result = await self.user_manager.sso_initiate(
|
|
262
284
|
Platforms.GOOGLE.value, "invalid_operation"
|
|
263
285
|
)
|
|
264
286
|
|
|
@@ -269,8 +291,8 @@ class TestJacAPIServerSSO:
|
|
|
269
291
|
@pytest.mark.asyncio
|
|
270
292
|
async def test_sso_initiate_when_get_sso_fails(self) -> None:
|
|
271
293
|
"""Test SSO initiation when get_sso returns None."""
|
|
272
|
-
with patch.object(self.
|
|
273
|
-
result = await self.
|
|
294
|
+
with patch.object(self.user_manager, "get_sso", return_value=None):
|
|
295
|
+
result = await self.user_manager.sso_initiate(
|
|
274
296
|
Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
275
297
|
)
|
|
276
298
|
|
|
@@ -281,28 +303,25 @@ class TestJacAPIServerSSO:
|
|
|
281
303
|
@pytest.mark.asyncio
|
|
282
304
|
async def test_sso_callback_login_success(self) -> None:
|
|
283
305
|
"""Test successful SSO callback for login."""
|
|
284
|
-
# Mock request
|
|
285
306
|
mock_request = Mock(spec=Request)
|
|
286
307
|
|
|
287
|
-
|
|
288
|
-
self.mock_user_manager.get_user.return_value = {
|
|
308
|
+
self.user_manager.get_user.return_value = {
|
|
289
309
|
"email": "test@example.com",
|
|
290
310
|
"root_id": str(uuid4()),
|
|
291
311
|
}
|
|
292
312
|
|
|
293
|
-
# Mock GoogleSSO
|
|
294
313
|
mock_sso = MockGoogleSSO("id", "secret", "uri")
|
|
295
314
|
mock_sso.verify_and_process = AsyncMock(
|
|
296
315
|
return_value=MockUserInfo(email="test@example.com")
|
|
297
316
|
)
|
|
298
317
|
|
|
299
318
|
with (
|
|
300
|
-
patch.object(self.
|
|
319
|
+
patch.object(self.user_manager, "get_sso", return_value=mock_sso),
|
|
301
320
|
patch.object(
|
|
302
|
-
self.
|
|
321
|
+
self.user_manager, "create_jwt_token", return_value="mock_jwt_token"
|
|
303
322
|
),
|
|
304
323
|
):
|
|
305
|
-
result = await self.
|
|
324
|
+
result = await self.user_manager.sso_callback(
|
|
306
325
|
mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
307
326
|
)
|
|
308
327
|
|
|
@@ -316,93 +335,73 @@ class TestJacAPIServerSSO:
|
|
|
316
335
|
@pytest.mark.asyncio
|
|
317
336
|
async def test_sso_callback_register_success(self) -> None:
|
|
318
337
|
"""Test successful SSO callback for registration."""
|
|
319
|
-
# Mock request
|
|
320
338
|
mock_request = Mock(spec=Request)
|
|
321
339
|
|
|
322
|
-
|
|
323
|
-
self.
|
|
324
|
-
self.mock_user_manager.create_user.return_value = {
|
|
340
|
+
self.user_manager.get_user.return_value = None
|
|
341
|
+
self.user_manager.create_user.return_value = {
|
|
325
342
|
"email": "newuser@example.com",
|
|
326
343
|
"root_id": str(uuid4()),
|
|
327
344
|
}
|
|
328
345
|
|
|
329
|
-
# Mock GoogleSSO
|
|
330
346
|
mock_sso = MockGoogleSSO("id", "secret", "uri")
|
|
331
347
|
mock_sso.verify_and_process = AsyncMock(
|
|
332
348
|
return_value=MockUserInfo(email="newuser@example.com")
|
|
333
349
|
)
|
|
334
350
|
|
|
335
351
|
with (
|
|
336
|
-
patch.object(self.
|
|
352
|
+
patch.object(self.user_manager, "get_sso", return_value=mock_sso),
|
|
337
353
|
patch.object(
|
|
338
|
-
self.
|
|
354
|
+
self.user_manager, "create_jwt_token", return_value="mock_jwt_token"
|
|
339
355
|
),
|
|
340
356
|
patch(
|
|
341
|
-
"jac_scale.
|
|
357
|
+
"jac_scale.user_manager.generate_random_password",
|
|
342
358
|
return_value="random_pass",
|
|
343
359
|
),
|
|
344
360
|
):
|
|
345
|
-
result = await self.
|
|
361
|
+
result = await self.user_manager.sso_callback(
|
|
346
362
|
mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
|
|
347
363
|
)
|
|
348
364
|
|
|
349
365
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
350
|
-
|
|
351
|
-
self.mock_user_manager.create_user.assert_called_once_with(
|
|
366
|
+
self.user_manager.create_user.assert_called_once_with(
|
|
352
367
|
"newuser@example.com", "random_pass"
|
|
353
368
|
)
|
|
354
369
|
|
|
355
370
|
@pytest.mark.asyncio
|
|
356
371
|
async def test_sso_callback_login_user_not_found(self) -> None:
|
|
357
372
|
"""Test SSO callback for login when user doesn't exist."""
|
|
358
|
-
# Mock request
|
|
359
373
|
mock_request = Mock(spec=Request)
|
|
360
|
-
|
|
361
|
-
# Mock user manager - user doesn't exist
|
|
362
|
-
self.mock_user_manager.get_user.return_value = None
|
|
363
|
-
|
|
364
|
-
# Mock GoogleSSO
|
|
374
|
+
self.user_manager.get_user.return_value = None
|
|
365
375
|
mock_sso = MockGoogleSSO("id", "secret", "uri")
|
|
366
376
|
mock_sso.verify_and_process = AsyncMock(
|
|
367
377
|
return_value=MockUserInfo(email="nonexistent@example.com")
|
|
368
378
|
)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
result = await self.server.sso_callback(
|
|
379
|
+
with patch.object(self.user_manager, "get_sso", return_value=mock_sso):
|
|
380
|
+
result = await self.user_manager.sso_callback(
|
|
372
381
|
mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
373
382
|
)
|
|
374
|
-
|
|
375
383
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
376
384
|
body = self._get_response_body(result)
|
|
377
|
-
|
|
378
385
|
assert "User not found" in body
|
|
379
386
|
|
|
380
387
|
@pytest.mark.asyncio
|
|
381
388
|
async def test_sso_callback_register_user_already_exists(self) -> None:
|
|
382
389
|
"""Test SSO callback for registration when user already exists."""
|
|
383
|
-
# Mock request
|
|
384
390
|
mock_request = Mock(spec=Request)
|
|
385
|
-
|
|
386
|
-
# Mock user manager - user already exists
|
|
387
|
-
self.mock_user_manager.get_user.return_value = {
|
|
391
|
+
self.user_manager.get_user.return_value = {
|
|
388
392
|
"email": "existing@example.com",
|
|
389
393
|
"root_id": str(uuid4()),
|
|
390
394
|
}
|
|
391
|
-
|
|
392
|
-
# Mock GoogleSSO
|
|
393
395
|
mock_sso = MockGoogleSSO("id", "secret", "uri")
|
|
394
396
|
mock_sso.verify_and_process = AsyncMock(
|
|
395
397
|
return_value=MockUserInfo(email="existing@example.com")
|
|
396
398
|
)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
result = await self.server.sso_callback(
|
|
399
|
+
with patch.object(self.user_manager, "get_sso", return_value=mock_sso):
|
|
400
|
+
result = await self.user_manager.sso_callback(
|
|
400
401
|
mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
|
|
401
402
|
)
|
|
402
|
-
|
|
403
403
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
404
404
|
body = self._get_response_body(result)
|
|
405
|
-
|
|
406
405
|
assert "User already exists" in body
|
|
407
406
|
|
|
408
407
|
@pytest.mark.asyncio
|
|
@@ -410,10 +409,9 @@ class TestJacAPIServerSSO:
|
|
|
410
409
|
"""Test SSO callback with invalid platform."""
|
|
411
410
|
mock_request = Mock(spec=Request)
|
|
412
411
|
|
|
413
|
-
result = await self.
|
|
412
|
+
result = await self.user_manager.sso_callback(
|
|
414
413
|
mock_request, "invalid_platform", Operations.LOGIN.value
|
|
415
414
|
)
|
|
416
|
-
|
|
417
415
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
418
416
|
body = self._get_response_body(result)
|
|
419
417
|
assert "Invalid platform" in body
|
|
@@ -422,14 +420,10 @@ class TestJacAPIServerSSO:
|
|
|
422
420
|
async def test_sso_callback_with_unconfigured_platform(self) -> None:
|
|
423
421
|
"""Test SSO callback with unconfigured platform."""
|
|
424
422
|
mock_request = Mock(spec=Request)
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
self.server.SUPPORTED_PLATFORMS = {}
|
|
428
|
-
|
|
429
|
-
result = await self.server.sso_callback(
|
|
423
|
+
self.user_manager.SUPPORTED_PLATFORMS = {}
|
|
424
|
+
result = await self.user_manager.sso_callback(
|
|
430
425
|
mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
431
426
|
)
|
|
432
|
-
|
|
433
427
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
434
428
|
body = self._get_response_body(result)
|
|
435
429
|
assert "not configured" in body
|
|
@@ -438,11 +432,9 @@ class TestJacAPIServerSSO:
|
|
|
438
432
|
async def test_sso_callback_with_invalid_operation(self) -> None:
|
|
439
433
|
"""Test SSO callback with invalid operation."""
|
|
440
434
|
mock_request = Mock(spec=Request)
|
|
441
|
-
|
|
442
|
-
result = await self.server.sso_callback(
|
|
435
|
+
result = await self.user_manager.sso_callback(
|
|
443
436
|
mock_request, Platforms.GOOGLE.value, "invalid_operation"
|
|
444
437
|
)
|
|
445
|
-
|
|
446
438
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
447
439
|
body = self._get_response_body(result)
|
|
448
440
|
assert "Invalid operation" in body
|
|
@@ -451,12 +443,10 @@ class TestJacAPIServerSSO:
|
|
|
451
443
|
async def test_sso_callback_when_get_sso_fails(self) -> None:
|
|
452
444
|
"""Test SSO callback when get_sso returns None."""
|
|
453
445
|
mock_request = Mock(spec=Request)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
result = await self.server.sso_callback(
|
|
446
|
+
with patch.object(self.user_manager, "get_sso", return_value=None):
|
|
447
|
+
result = await self.user_manager.sso_callback(
|
|
457
448
|
mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
458
449
|
)
|
|
459
|
-
|
|
460
450
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
461
451
|
body = self._get_response_body(result)
|
|
462
452
|
assert "Failed to initialize SSO" in body
|
|
@@ -465,117 +455,37 @@ class TestJacAPIServerSSO:
|
|
|
465
455
|
async def test_sso_callback_when_email_not_provided(self) -> None:
|
|
466
456
|
"""Test SSO callback when email is not provided by SSO provider."""
|
|
467
457
|
mock_request = Mock(spec=Request)
|
|
468
|
-
|
|
469
|
-
# Mock GoogleSSO with no email
|
|
470
458
|
mock_sso = MockGoogleSSO("id", "secret", "uri")
|
|
471
459
|
mock_user_info = MockUserInfo(email="")
|
|
472
460
|
mock_user_info.email = None # type: ignore
|
|
473
461
|
mock_sso.verify_and_process = AsyncMock(return_value=mock_user_info)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
result = await self.server.sso_callback(
|
|
462
|
+
with patch.object(self.user_manager, "get_sso", return_value=mock_sso):
|
|
463
|
+
result = await self.user_manager.sso_callback(
|
|
477
464
|
mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
478
465
|
)
|
|
479
|
-
|
|
480
466
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
481
467
|
body = self._get_response_body(result)
|
|
482
|
-
|
|
483
468
|
assert "Email not provided" in body
|
|
484
469
|
|
|
485
470
|
@pytest.mark.asyncio
|
|
486
471
|
async def test_sso_callback_authentication_failure(self) -> None:
|
|
487
472
|
"""Test SSO callback when authentication fails."""
|
|
488
473
|
mock_request = Mock(spec=Request)
|
|
489
|
-
|
|
490
|
-
# Mock GoogleSSO that raises exception
|
|
491
474
|
mock_sso = MockGoogleSSO("id", "secret", "uri")
|
|
492
475
|
mock_sso.verify_and_process = AsyncMock(
|
|
493
476
|
side_effect=Exception("Authentication failed")
|
|
494
477
|
)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
result = await self.server.sso_callback(
|
|
478
|
+
with patch.object(self.user_manager, "get_sso", return_value=mock_sso):
|
|
479
|
+
result = await self.user_manager.sso_callback(
|
|
498
480
|
mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
499
481
|
)
|
|
500
|
-
|
|
501
482
|
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
502
483
|
body = self._get_response_body(result)
|
|
503
|
-
|
|
504
484
|
assert "Authentication failed" in body
|
|
505
485
|
|
|
506
|
-
@pytest.mark.asyncio
|
|
507
|
-
async def test_sso_callback_register_create_user_error(self) -> None:
|
|
508
|
-
"""Test SSO callback for registration when create_user returns error."""
|
|
509
|
-
mock_request = Mock(spec=Request)
|
|
510
|
-
|
|
511
|
-
# Mock user manager
|
|
512
|
-
self.mock_user_manager.get_user.return_value = None
|
|
513
|
-
self.mock_user_manager.create_user.return_value = {
|
|
514
|
-
"error": "Failed to create user"
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
# Mock GoogleSSO
|
|
518
|
-
mock_sso = MockGoogleSSO("id", "secret", "uri")
|
|
519
|
-
mock_sso.verify_and_process = AsyncMock(
|
|
520
|
-
return_value=MockUserInfo(email="error@example.com")
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
with (
|
|
524
|
-
patch.object(self.server, "get_sso", return_value=mock_sso),
|
|
525
|
-
patch(
|
|
526
|
-
"jac_scale.serve.generate_random_password", return_value="random_pass"
|
|
527
|
-
),
|
|
528
|
-
):
|
|
529
|
-
result = await self.server.sso_callback(
|
|
530
|
-
mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
|
|
531
|
-
)
|
|
532
|
-
|
|
533
|
-
assert isinstance(result, (JSONResponse, TransportResponse))
|
|
534
|
-
body = self._get_response_body(result)
|
|
535
|
-
|
|
536
|
-
assert "Failed to create user" in body
|
|
537
|
-
|
|
538
|
-
def test_register_sso_endpoints(self) -> None:
|
|
539
|
-
"""Test SSO endpoints registration."""
|
|
540
|
-
# Reset mock
|
|
541
|
-
self.mock_server_impl.reset_mock()
|
|
542
|
-
|
|
543
|
-
# Register SSO endpoints
|
|
544
|
-
self.server.register_sso_endpoints()
|
|
545
|
-
|
|
546
|
-
# Verify that add_endpoint was called for both endpoints
|
|
547
|
-
assert self.mock_server_impl.add_endpoint.call_count == 2
|
|
548
|
-
|
|
549
|
-
# Get the calls
|
|
550
|
-
calls = self.mock_server_impl.add_endpoint.call_args_list
|
|
551
|
-
|
|
552
|
-
# Check first endpoint (initiate)
|
|
553
|
-
first_endpoint = calls[0][0][0]
|
|
554
|
-
assert "/sso/{platform}/{operation}" in first_endpoint.path
|
|
555
|
-
assert first_endpoint.method.name == "GET"
|
|
556
|
-
assert "SSO APIs" in first_endpoint.tags
|
|
557
|
-
|
|
558
|
-
# Check second endpoint (callback)
|
|
559
|
-
second_endpoint = calls[1][0][0]
|
|
560
|
-
assert "/sso/{platform}/{operation}/callback" in second_endpoint.path
|
|
561
|
-
assert second_endpoint.method.name == "GET"
|
|
562
|
-
assert "SSO APIs" in second_endpoint.tags
|
|
563
|
-
|
|
564
|
-
def test_platforms_enum(self) -> None:
|
|
565
|
-
"""Test Platforms enum values."""
|
|
566
|
-
assert Platforms.GOOGLE.value == "google"
|
|
567
|
-
assert len(list(Platforms)) == 1 # Only Google is currently supported
|
|
568
|
-
|
|
569
|
-
def test_operations_enum(self) -> None:
|
|
570
|
-
"""Test Operations enum values."""
|
|
571
|
-
assert Operations.LOGIN.value == "login"
|
|
572
|
-
assert Operations.REGISTER.value == "register"
|
|
573
|
-
assert len(list(Operations)) == 2
|
|
574
|
-
|
|
575
486
|
def test_supported_platforms_initialization_with_jac_toml_credentials(self) -> None:
|
|
576
487
|
"""Test SUPPORTED_PLATFORMS initialization when credentials are in jac.toml."""
|
|
577
488
|
reset_scale_config()
|
|
578
|
-
# Mock config with credentials (simulating [plugins.scale.sso.google] in jac.toml)
|
|
579
489
|
mock_config = MockScaleConfig(
|
|
580
490
|
{
|
|
581
491
|
"host": "http://localhost:8000/sso",
|
|
@@ -585,17 +495,13 @@ class TestJacAPIServerSSO:
|
|
|
585
495
|
},
|
|
586
496
|
}
|
|
587
497
|
)
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
)
|
|
593
|
-
|
|
594
|
-
assert "google" in server.SUPPORTED_PLATFORMS
|
|
595
|
-
assert server.SUPPORTED_PLATFORMS["google"]["client_id"] == "toml_test_id"
|
|
498
|
+
# Patch both places where config is loaded
|
|
499
|
+
with patch("jac_scale.user_manager.get_scale_config", return_value=mock_config):
|
|
500
|
+
user_manager = JacScaleUserManager(base_path="")
|
|
501
|
+
assert "google" in user_manager.SUPPORTED_PLATFORMS
|
|
596
502
|
assert (
|
|
597
|
-
|
|
598
|
-
== "
|
|
503
|
+
user_manager.SUPPORTED_PLATFORMS["google"]["client_id"]
|
|
504
|
+
== "toml_test_id"
|
|
599
505
|
)
|
|
600
506
|
|
|
601
507
|
def test_supported_platforms_initialization_without_jac_toml_credentials(
|
|
@@ -603,109 +509,192 @@ class TestJacAPIServerSSO:
|
|
|
603
509
|
) -> None:
|
|
604
510
|
"""Test SUPPORTED_PLATFORMS initialization when credentials are missing from jac.toml."""
|
|
605
511
|
reset_scale_config()
|
|
606
|
-
# Mock config without credentials (simulating empty sso section in jac.toml)
|
|
607
512
|
mock_config = MockScaleConfig(mock_sso_config_without_credentials())
|
|
608
|
-
with patch("jac_scale.
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
513
|
+
with patch("jac_scale.user_manager.get_scale_config", return_value=mock_config):
|
|
514
|
+
user_manager = JacScaleUserManager(base_path="")
|
|
515
|
+
assert "google" not in user_manager.SUPPORTED_PLATFORMS
|
|
516
|
+
|
|
517
|
+
def test_link_sso_account_success(self) -> None:
|
|
518
|
+
"""Test linking an SSO account successfully."""
|
|
519
|
+
# Setup: Create a user in the DB directly to satisfy Foreign Key
|
|
520
|
+
self.user_manager._ensure_connection()
|
|
521
|
+
self.user_manager._conn.execute(
|
|
522
|
+
"INSERT INTO users (username, password_hash, token, root_id) VALUES (?, ?, ?, ?)",
|
|
523
|
+
("user1", "hash", "token", "root"),
|
|
524
|
+
)
|
|
525
|
+
self.user_manager._conn.commit()
|
|
616
526
|
|
|
617
|
-
|
|
618
|
-
self
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
527
|
+
# Link account
|
|
528
|
+
result = self.user_manager.link_sso_account(
|
|
529
|
+
"user1", "google", "ext_123", "test@google.com"
|
|
530
|
+
)
|
|
531
|
+
assert result.get("message") == "SSO account linked successfully"
|
|
532
|
+
assert result.get("user_id") == "user1"
|
|
533
|
+
|
|
534
|
+
# Verify in DB
|
|
535
|
+
accounts = self.user_manager.get_sso_accounts("user1")
|
|
536
|
+
assert len(accounts) == 1
|
|
537
|
+
assert accounts[0]["platform"] == "google"
|
|
538
|
+
assert accounts[0]["external_id"] == "ext_123"
|
|
539
|
+
|
|
540
|
+
def test_link_sso_account_duplicate(self) -> None:
|
|
541
|
+
"""Test preventing duplicate SSO account linking."""
|
|
542
|
+
# Setup: Create users
|
|
543
|
+
self.user_manager._ensure_connection()
|
|
544
|
+
self.user_manager._conn.execute(
|
|
545
|
+
"INSERT INTO users (username, password_hash, token, root_id) VALUES (?, ?, ?, ?)",
|
|
546
|
+
("user1", "hash", "token", "root"),
|
|
547
|
+
)
|
|
548
|
+
self.user_manager._conn.execute(
|
|
549
|
+
"INSERT INTO users (username, password_hash, token, root_id) VALUES (?, ?, ?, ?)",
|
|
550
|
+
("user2", "hash", "token2", "root2"),
|
|
551
|
+
)
|
|
552
|
+
self.user_manager._conn.commit()
|
|
629
553
|
|
|
630
|
-
|
|
631
|
-
|
|
554
|
+
# Link first time
|
|
555
|
+
self.user_manager.link_sso_account(
|
|
556
|
+
"user1", "google", "ext_123", "test@google.com"
|
|
557
|
+
)
|
|
632
558
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
559
|
+
# Attempt to link same SSO account to user2
|
|
560
|
+
result = self.user_manager.link_sso_account(
|
|
561
|
+
"user2", "google", "ext_123", "test@google.com"
|
|
562
|
+
)
|
|
563
|
+
assert "already linked to another user" in result.get("error", "")
|
|
637
564
|
|
|
638
|
-
#
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
565
|
+
# Attempt to link same SSO account to user1 again
|
|
566
|
+
result = self.user_manager.link_sso_account(
|
|
567
|
+
"user1", "google", "ext_123", "test@google.com"
|
|
568
|
+
)
|
|
569
|
+
assert "already linked to this user" in result.get("message", "")
|
|
570
|
+
|
|
571
|
+
def test_unlink_sso_account(self) -> None:
|
|
572
|
+
"""Test unlinking an SSO account."""
|
|
573
|
+
self.user_manager._ensure_connection()
|
|
574
|
+
self.user_manager._conn.execute(
|
|
575
|
+
"INSERT INTO users (username, password_hash, token, root_id) VALUES (?, ?, ?, ?)",
|
|
576
|
+
("user1", "hash", "token", "root"),
|
|
577
|
+
)
|
|
578
|
+
self.user_manager._conn.commit()
|
|
644
579
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
mock_sso.verify_and_process = AsyncMock(
|
|
648
|
-
return_value=MockUserInfo(email=user_email)
|
|
580
|
+
self.user_manager.link_sso_account(
|
|
581
|
+
"user1", "google", "ext_123", "test@google.com"
|
|
649
582
|
)
|
|
650
583
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
self.server, "create_jwt_token", return_value="generated_token"
|
|
655
|
-
) as mock_create_token,
|
|
656
|
-
):
|
|
657
|
-
result = await self.server.sso_callback(
|
|
658
|
-
mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
|
|
659
|
-
)
|
|
584
|
+
# Unlink
|
|
585
|
+
result = self.user_manager.unlink_sso_account("user1", "google")
|
|
586
|
+
assert result.get("message") == "SSO account unlinked successfully"
|
|
660
587
|
|
|
661
|
-
|
|
662
|
-
|
|
588
|
+
# Verify empty
|
|
589
|
+
accounts = self.user_manager.get_sso_accounts("user1")
|
|
590
|
+
assert len(accounts) == 0
|
|
663
591
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
592
|
+
def test_get_user_by_sso(self) -> None:
|
|
593
|
+
"""Test retrieving user by SSO credentials."""
|
|
594
|
+
self.user_manager._ensure_connection()
|
|
595
|
+
self.user_manager._conn.execute(
|
|
596
|
+
"INSERT INTO users (username, password_hash, token, root_id) VALUES (?, ?, ?, ?)",
|
|
597
|
+
("user1", "hash", "token_val", "root_val"),
|
|
598
|
+
)
|
|
599
|
+
self.user_manager._conn.commit()
|
|
667
600
|
|
|
668
|
-
|
|
601
|
+
self.user_manager.link_sso_account(
|
|
602
|
+
"user1", "google", "ext_123", "test@google.com"
|
|
603
|
+
)
|
|
669
604
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
605
|
+
# Find user
|
|
606
|
+
user = self.user_manager.get_user_by_sso("google", "ext_123")
|
|
607
|
+
assert user is not None
|
|
608
|
+
assert user["email"] == "user1"
|
|
609
|
+
assert user["token"] == "token_val"
|
|
610
|
+
assert user["root_id"] == "root_val"
|
|
674
611
|
|
|
675
|
-
|
|
612
|
+
# Find non-existent
|
|
613
|
+
user = self.user_manager.get_user_by_sso("google", "nonexistent")
|
|
614
|
+
assert user is None
|
|
676
615
|
|
|
677
|
-
# Mock user manager
|
|
678
|
-
self.mock_user_manager.get_user.return_value = None
|
|
679
|
-
self.mock_user_manager.create_user.return_value = {
|
|
680
|
-
"email": user_email,
|
|
681
|
-
"root_id": str(uuid4()),
|
|
682
|
-
}
|
|
683
616
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
)
|
|
617
|
+
class TestJacAPIServerEndpoints:
|
|
618
|
+
"""Test SSO endpoints registration in JacAPIServer."""
|
|
619
|
+
|
|
620
|
+
def setup_method(self) -> None:
|
|
621
|
+
self.mock_server_impl = Mock()
|
|
622
|
+
self.mock_user_manager = Mock()
|
|
623
|
+
self.mock_config = MockScaleConfig(mock_sso_config_with_credentials())
|
|
689
624
|
|
|
690
625
|
with (
|
|
691
|
-
patch
|
|
692
|
-
patch.object(
|
|
693
|
-
self.server, "create_jwt_token", return_value="new_user_token"
|
|
694
|
-
) as mock_create_token,
|
|
626
|
+
patch("jac_scale.serve.get_scale_config", return_value=self.mock_config),
|
|
695
627
|
patch(
|
|
696
|
-
"
|
|
697
|
-
return_value=
|
|
628
|
+
"jaclang.pycore.runtime.JacRuntimeInterface.get_user_manager",
|
|
629
|
+
return_value=self.mock_user_manager,
|
|
698
630
|
),
|
|
699
631
|
):
|
|
700
|
-
|
|
701
|
-
mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
|
|
702
|
-
)
|
|
632
|
+
self.server = JacAPIServer(module_name="test_module", port=8000)
|
|
703
633
|
|
|
704
|
-
|
|
705
|
-
mock_create_token.assert_called_once_with(user_email)
|
|
634
|
+
self.server.server = self.mock_server_impl
|
|
706
635
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
636
|
+
def test_register_sso_endpoints(self) -> None:
|
|
637
|
+
"""Test SSO endpoints registration."""
|
|
638
|
+
self.mock_server_impl.reset_mock()
|
|
639
|
+
self.server.register_sso_endpoints()
|
|
640
|
+
assert self.mock_server_impl.add_endpoint.call_count == 2
|
|
641
|
+
calls = self.mock_server_impl.add_endpoint.call_args_list
|
|
642
|
+
first_endpoint = calls[0][0][0]
|
|
643
|
+
assert "/sso/{platform}/{operation}" in first_endpoint.path
|
|
644
|
+
assert first_endpoint.method.name == "GET"
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
class TestGoogleSSOProvider:
|
|
648
|
+
"""Test GoogleSSOProvider wrapper implementation."""
|
|
649
|
+
|
|
650
|
+
def setup_method(self) -> None:
|
|
651
|
+
"""Setup mock provider."""
|
|
652
|
+
with patch("jac_scale.google_sso_provider.GoogleSSO") as mock_cls:
|
|
653
|
+
self.mock_inner_sso = Mock()
|
|
654
|
+
mock_cls.return_value = self.mock_inner_sso
|
|
655
|
+
self.provider = GoogleSSOProvider("id", "secret", "uri")
|
|
710
656
|
|
|
711
|
-
|
|
657
|
+
# Since postinit is called in __init__ (Jac feature), we check if inner sso is set
|
|
658
|
+
assert self.provider._google_sso == self.mock_inner_sso
|
|
659
|
+
|
|
660
|
+
@pytest.mark.asyncio
|
|
661
|
+
async def test_initiate_auth(self) -> None:
|
|
662
|
+
"""Test initiate_auth delegates to inner SSO."""
|
|
663
|
+
expected_response = Mock()
|
|
664
|
+
self.mock_inner_sso.get_login_redirect = AsyncMock(
|
|
665
|
+
return_value=expected_response
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# We need to mock __enter__/__exit__ for the 'with self._google_sso' block in Jac
|
|
669
|
+
self.mock_inner_sso.__enter__ = Mock(return_value=self.mock_inner_sso)
|
|
670
|
+
self.mock_inner_sso.__exit__ = Mock(return_value=None)
|
|
671
|
+
|
|
672
|
+
response = await self.provider.initiate_auth("login")
|
|
673
|
+
|
|
674
|
+
self.mock_inner_sso.get_login_redirect.assert_called_once()
|
|
675
|
+
assert response == expected_response
|
|
676
|
+
|
|
677
|
+
@pytest.mark.asyncio
|
|
678
|
+
async def test_handle_callback(self) -> None:
|
|
679
|
+
"""Test handle_callback delegates and maps user info."""
|
|
680
|
+
mock_request = Mock(spec=Request)
|
|
681
|
+
mock_user = Mock()
|
|
682
|
+
mock_user.email = "test@example.com"
|
|
683
|
+
mock_user.id = "12345"
|
|
684
|
+
mock_user.display_name = "Test User"
|
|
685
|
+
|
|
686
|
+
self.mock_inner_sso.verify_and_process = AsyncMock(return_value=mock_user)
|
|
687
|
+
self.mock_inner_sso.__enter__ = Mock(return_value=self.mock_inner_sso)
|
|
688
|
+
self.mock_inner_sso.__exit__ = Mock(return_value=None)
|
|
689
|
+
|
|
690
|
+
result = await self.provider.handle_callback(mock_request)
|
|
691
|
+
|
|
692
|
+
self.mock_inner_sso.verify_and_process.assert_called_once_with(mock_request)
|
|
693
|
+
assert result.email == "test@example.com"
|
|
694
|
+
assert result.external_id == "12345"
|
|
695
|
+
assert result.platform == "google"
|
|
696
|
+
assert result.display_name == "Test User"
|
|
697
|
+
|
|
698
|
+
def test_get_platform_name(self) -> None:
|
|
699
|
+
"""Test get_platform_name returns google."""
|
|
700
|
+
assert self.provider.get_platform_name() == "google"
|