jac-scale 0.1.1__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.
Files changed (57) hide show
  1. jac_scale/__init__.py +0 -0
  2. jac_scale/abstractions/config/app_config.jac +30 -0
  3. jac_scale/abstractions/config/base_config.jac +26 -0
  4. jac_scale/abstractions/database_provider.jac +51 -0
  5. jac_scale/abstractions/deployment_target.jac +64 -0
  6. jac_scale/abstractions/image_registry.jac +54 -0
  7. jac_scale/abstractions/logger.jac +20 -0
  8. jac_scale/abstractions/models/deployment_result.jac +27 -0
  9. jac_scale/abstractions/models/resource_status.jac +38 -0
  10. jac_scale/config_loader.jac +31 -0
  11. jac_scale/context.jac +14 -0
  12. jac_scale/factories/database_factory.jac +43 -0
  13. jac_scale/factories/deployment_factory.jac +43 -0
  14. jac_scale/factories/registry_factory.jac +32 -0
  15. jac_scale/factories/utility_factory.jac +34 -0
  16. jac_scale/impl/config_loader.impl.jac +131 -0
  17. jac_scale/impl/context.impl.jac +24 -0
  18. jac_scale/impl/memory_hierarchy.main.impl.jac +63 -0
  19. jac_scale/impl/memory_hierarchy.mongo.impl.jac +239 -0
  20. jac_scale/impl/memory_hierarchy.redis.impl.jac +186 -0
  21. jac_scale/impl/serve.impl.jac +1785 -0
  22. jac_scale/jserver/__init__.py +0 -0
  23. jac_scale/jserver/impl/jfast_api.impl.jac +731 -0
  24. jac_scale/jserver/impl/jserver.impl.jac +79 -0
  25. jac_scale/jserver/jfast_api.jac +162 -0
  26. jac_scale/jserver/jserver.jac +101 -0
  27. jac_scale/memory_hierarchy.jac +138 -0
  28. jac_scale/plugin.jac +218 -0
  29. jac_scale/plugin_config.jac +175 -0
  30. jac_scale/providers/database/kubernetes_mongo.jac +137 -0
  31. jac_scale/providers/database/kubernetes_redis.jac +110 -0
  32. jac_scale/providers/registry/dockerhub.jac +64 -0
  33. jac_scale/serve.jac +118 -0
  34. jac_scale/targets/kubernetes/kubernetes_config.jac +215 -0
  35. jac_scale/targets/kubernetes/kubernetes_target.jac +841 -0
  36. jac_scale/targets/kubernetes/utils/kubernetes_utils.impl.jac +519 -0
  37. jac_scale/targets/kubernetes/utils/kubernetes_utils.jac +85 -0
  38. jac_scale/tests/__init__.py +0 -0
  39. jac_scale/tests/conftest.py +29 -0
  40. jac_scale/tests/fixtures/test_api.jac +159 -0
  41. jac_scale/tests/fixtures/todo_app.jac +68 -0
  42. jac_scale/tests/test_abstractions.py +88 -0
  43. jac_scale/tests/test_deploy_k8s.py +265 -0
  44. jac_scale/tests/test_examples.py +484 -0
  45. jac_scale/tests/test_factories.py +149 -0
  46. jac_scale/tests/test_file_upload.py +444 -0
  47. jac_scale/tests/test_k8s_utils.py +156 -0
  48. jac_scale/tests/test_memory_hierarchy.py +247 -0
  49. jac_scale/tests/test_serve.py +1835 -0
  50. jac_scale/tests/test_sso.py +711 -0
  51. jac_scale/utilities/loggers/standard_logger.jac +40 -0
  52. jac_scale/utils.jac +16 -0
  53. jac_scale-0.1.1.dist-info/METADATA +658 -0
  54. jac_scale-0.1.1.dist-info/RECORD +57 -0
  55. jac_scale-0.1.1.dist-info/WHEEL +5 -0
  56. jac_scale-0.1.1.dist-info/entry_points.txt +3 -0
  57. jac_scale-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,711 @@
1
+ """Test for SSO (Single Sign-On) implementation in jac-scale."""
2
+
3
+ import contextlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from types import TracebackType
7
+ from unittest.mock import AsyncMock, Mock, patch
8
+ from uuid import uuid4
9
+
10
+ import pytest
11
+ from fastapi import Request
12
+ from fastapi.responses import JSONResponse, RedirectResponse
13
+
14
+ from jac_scale.config_loader import reset_scale_config
15
+ from jac_scale.serve import JacAPIServer, Operations, Platforms
16
+ from jaclang.runtimelib.transport import TransportResponse
17
+
18
+
19
+ def mock_sso_config_with_credentials() -> dict:
20
+ """Return mock SSO config with Google credentials configured."""
21
+ return {
22
+ "host": "http://localhost:8000/sso",
23
+ "google": {
24
+ "client_id": "test_client_id",
25
+ "client_secret": "test_client_secret",
26
+ },
27
+ }
28
+
29
+
30
+ def mock_sso_config_without_credentials() -> dict:
31
+ """Return mock SSO config without credentials."""
32
+ return {
33
+ "host": "http://localhost:8000/sso",
34
+ "google": {
35
+ "client_id": "",
36
+ "client_secret": "",
37
+ },
38
+ }
39
+
40
+
41
+ def mock_sso_config_partial_credentials() -> dict:
42
+ """Return mock SSO config with only client_id (no secret)."""
43
+ return {
44
+ "host": "http://localhost:8000/sso",
45
+ "google": {
46
+ "client_id": "test_id",
47
+ "client_secret": "",
48
+ },
49
+ }
50
+
51
+
52
+ @dataclass
53
+ class MockUserInfo:
54
+ """Mock user info from SSO provider."""
55
+
56
+ email: str
57
+ id: str = "mock_sso_id"
58
+ first_name: str = "Test"
59
+ last_name: str = "User"
60
+ display_name: str = "Test User"
61
+ picture: str = "https://example.com/picture.jpg"
62
+
63
+
64
+ class MockGoogleSSO:
65
+ """Mock GoogleSSO for testing."""
66
+
67
+ def __init__(
68
+ self,
69
+ client_id: str,
70
+ client_secret: str,
71
+ redirect_uri: str,
72
+ allow_insecure_http: bool = False,
73
+ ):
74
+ self.client_id = client_id
75
+ self.client_secret = client_secret
76
+ self.redirect_uri = redirect_uri
77
+ self.allow_insecure_http = allow_insecure_http
78
+ # Set default callables that can be overridden
79
+ self.get_login_redirect = self._default_get_login_redirect
80
+ self.verify_and_process = self._default_verify_and_process
81
+
82
+ async def _default_get_login_redirect(self) -> RedirectResponse:
83
+ """Mock get_login_redirect method."""
84
+ return RedirectResponse(url="https://accounts.google.com/oauth/authorize")
85
+
86
+ async def _default_verify_and_process(self, _request: Request) -> MockUserInfo:
87
+ """Mock verify_and_process method."""
88
+ return MockUserInfo(email="test@example.com")
89
+
90
+ def __enter__(self) -> "MockGoogleSSO":
91
+ return self
92
+
93
+ def __exit__(
94
+ self,
95
+ _exc_type: type[BaseException] | None,
96
+ _exc_val: BaseException | None,
97
+ _exc_tb: TracebackType | None,
98
+ ) -> None:
99
+ pass
100
+
101
+
102
+ class MockScaleConfig:
103
+ """Mock JacScaleConfig for testing."""
104
+
105
+ def __init__(self, sso_config: dict | None = None):
106
+ self._sso_config = sso_config or mock_sso_config_with_credentials()
107
+
108
+ def get_sso_config(self) -> dict:
109
+ return self._sso_config
110
+
111
+
112
+ class TestJacAPIServerSSO:
113
+ """Test SSO functionality in JacAPIServer."""
114
+
115
+ def setup_method(self) -> None:
116
+ """Setup for each test method."""
117
+ # Reset config singleton to ensure fresh config
118
+ reset_scale_config()
119
+
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
+ 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
+
134
+ # Replace server components with mocks
135
+ self.server.server = self.mock_server_impl
136
+ self.server.user_manager = self.mock_user_manager
137
+ self.server.introspector = self.mock_introspector
138
+ self.server.execution_manager = self.mock_execution_manager
139
+
140
+ def teardown_method(self) -> None:
141
+ """Teardown after each test."""
142
+ with contextlib.suppress(BaseException):
143
+ del self.server
144
+
145
+ @staticmethod
146
+ def _get_response_body(result: JSONResponse | TransportResponse) -> str:
147
+ """Extract body content from JSONResponse or TransportResponse."""
148
+ if isinstance(result, JSONResponse):
149
+ return result.body.decode("utf-8")
150
+ elif isinstance(result, TransportResponse):
151
+ # Convert TransportResponse to JSON string
152
+ response_dict = {
153
+ "ok": result.ok,
154
+ "type": result.type,
155
+ "data": result.data,
156
+ "error": None,
157
+ }
158
+ if not result.ok and result.error:
159
+ response_dict["error"] = {
160
+ "code": result.error.code,
161
+ "message": result.error.message,
162
+ "details": result.error.details,
163
+ }
164
+ if result.meta:
165
+ meta_dict = {}
166
+ if result.meta.request_id:
167
+ meta_dict["request_id"] = result.meta.request_id
168
+ if result.meta.trace_id:
169
+ meta_dict["trace_id"] = result.meta.trace_id
170
+ if result.meta.timestamp:
171
+ meta_dict["timestamp"] = result.meta.timestamp
172
+ if result.meta.extra:
173
+ meta_dict["extra"] = result.meta.extra
174
+ if meta_dict:
175
+ response_dict["meta"] = meta_dict
176
+ return json.dumps(response_dict)
177
+ else:
178
+ raise TypeError(f"Unexpected response type: {type(result)}")
179
+
180
+ def test_get_sso_with_google_platform(self) -> None:
181
+ """Test get_sso returns GoogleSSO instance for Google platform."""
182
+ with patch("jac_scale.serve.GoogleSSO", return_value=MockGoogleSSO) as mock_sso:
183
+ sso = self.server.get_sso(Platforms.GOOGLE.value, Operations.LOGIN.value)
184
+
185
+ assert sso is not None
186
+ mock_sso.assert_called_once()
187
+
188
+ def test_get_sso_with_invalid_platform(self) -> None:
189
+ """Test get_sso returns None for invalid platform."""
190
+ sso = self.server.get_sso("invalid_platform", Operations.LOGIN.value)
191
+ assert sso is None
192
+
193
+ def test_get_sso_with_unconfigured_platform(self) -> None:
194
+ """Test get_sso returns None when platform credentials are not configured in jac.toml."""
195
+ reset_scale_config()
196
+ # Mock config without credentials (simulating empty [plugins.scale.sso] in jac.toml)
197
+ mock_config = MockScaleConfig(mock_sso_config_without_credentials())
198
+ with patch("jac_scale.serve.get_scale_config", return_value=mock_config):
199
+ server = JacAPIServer(
200
+ module_name="test_module",
201
+ port=8000,
202
+ )
203
+ sso = server.get_sso(Platforms.GOOGLE.value, Operations.LOGIN.value)
204
+ assert sso is None
205
+
206
+ def test_get_sso_redirect_uri_format(self) -> None:
207
+ """Test get_sso creates correct redirect URI based on jac.toml SSO host."""
208
+ with patch("jac_scale.serve.GoogleSSO") as mock_sso:
209
+ self.server.get_sso(Platforms.GOOGLE.value, Operations.LOGIN.value)
210
+
211
+ # Check that GoogleSSO was called with correct redirect_uri
212
+ call_args = mock_sso.call_args
213
+ assert call_args is not None
214
+ assert (
215
+ call_args.kwargs["redirect_uri"]
216
+ == "http://localhost:8000/sso/google/login/callback"
217
+ )
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_sso_initiate_success(self) -> None:
221
+ """Test successful SSO initiation."""
222
+ with patch.object(
223
+ self.server, "get_sso", return_value=MockGoogleSSO("id", "secret", "uri")
224
+ ):
225
+ result = await self.server.sso_initiate(
226
+ Platforms.GOOGLE.value, Operations.LOGIN.value
227
+ )
228
+
229
+ assert isinstance(result, RedirectResponse)
230
+ assert "google.com" in result.headers.get("location", "")
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_sso_initiate_with_invalid_platform(self) -> None:
234
+ """Test SSO initiation with invalid platform."""
235
+ result = await self.server.sso_initiate(
236
+ "invalid_platform", Operations.LOGIN.value
237
+ )
238
+
239
+ assert isinstance(result, (JSONResponse, TransportResponse))
240
+ # Extract body from JSONResponse or TransportResponse
241
+ body = self._get_response_body(result)
242
+ assert "Invalid platform" in body
243
+
244
+ @pytest.mark.asyncio
245
+ async def test_sso_initiate_with_unconfigured_platform(self) -> None:
246
+ """Test SSO initiation with unconfigured platform."""
247
+ # Clear supported platforms
248
+ self.server.SUPPORTED_PLATFORMS = {}
249
+
250
+ result = await self.server.sso_initiate(
251
+ Platforms.GOOGLE.value, Operations.LOGIN.value
252
+ )
253
+
254
+ assert isinstance(result, (JSONResponse, TransportResponse))
255
+ body = self._get_response_body(result)
256
+ assert "not configured" in body
257
+
258
+ @pytest.mark.asyncio
259
+ async def test_sso_initiate_with_invalid_operation(self) -> None:
260
+ """Test SSO initiation with invalid operation."""
261
+ result = await self.server.sso_initiate(
262
+ Platforms.GOOGLE.value, "invalid_operation"
263
+ )
264
+
265
+ assert isinstance(result, (JSONResponse, TransportResponse))
266
+ body = self._get_response_body(result)
267
+ assert "Invalid operation" in body
268
+
269
+ @pytest.mark.asyncio
270
+ async def test_sso_initiate_when_get_sso_fails(self) -> None:
271
+ """Test SSO initiation when get_sso returns None."""
272
+ with patch.object(self.server, "get_sso", return_value=None):
273
+ result = await self.server.sso_initiate(
274
+ Platforms.GOOGLE.value, Operations.LOGIN.value
275
+ )
276
+
277
+ assert isinstance(result, (JSONResponse, TransportResponse))
278
+ body = self._get_response_body(result)
279
+ assert "Failed to initialize SSO" in body
280
+
281
+ @pytest.mark.asyncio
282
+ async def test_sso_callback_login_success(self) -> None:
283
+ """Test successful SSO callback for login."""
284
+ # Mock request
285
+ mock_request = Mock(spec=Request)
286
+
287
+ # Mock user manager
288
+ self.mock_user_manager.get_user.return_value = {
289
+ "email": "test@example.com",
290
+ "root_id": str(uuid4()),
291
+ }
292
+
293
+ # Mock GoogleSSO
294
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
295
+ mock_sso.verify_and_process = AsyncMock(
296
+ return_value=MockUserInfo(email="test@example.com")
297
+ )
298
+
299
+ with (
300
+ patch.object(self.server, "get_sso", return_value=mock_sso),
301
+ patch.object(
302
+ self.server, "create_jwt_token", return_value="mock_jwt_token"
303
+ ),
304
+ ):
305
+ result = await self.server.sso_callback(
306
+ mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
307
+ )
308
+
309
+ assert isinstance(result, (JSONResponse, TransportResponse))
310
+ body = self._get_response_body(result)
311
+
312
+ assert "Login successful" in body
313
+ assert "test@example.com" in body
314
+ assert "mock_jwt_token" in body
315
+
316
+ @pytest.mark.asyncio
317
+ async def test_sso_callback_register_success(self) -> None:
318
+ """Test successful SSO callback for registration."""
319
+ # Mock request
320
+ mock_request = Mock(spec=Request)
321
+
322
+ # Mock user manager - user doesn't exist
323
+ self.mock_user_manager.get_user.return_value = None
324
+ self.mock_user_manager.create_user.return_value = {
325
+ "email": "newuser@example.com",
326
+ "root_id": str(uuid4()),
327
+ }
328
+
329
+ # Mock GoogleSSO
330
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
331
+ mock_sso.verify_and_process = AsyncMock(
332
+ return_value=MockUserInfo(email="newuser@example.com")
333
+ )
334
+
335
+ with (
336
+ patch.object(self.server, "get_sso", return_value=mock_sso),
337
+ patch.object(
338
+ self.server, "create_jwt_token", return_value="mock_jwt_token"
339
+ ),
340
+ patch(
341
+ "jac_scale.serve.generate_random_password",
342
+ return_value="random_pass",
343
+ ),
344
+ ):
345
+ result = await self.server.sso_callback(
346
+ mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
347
+ )
348
+
349
+ assert isinstance(result, (JSONResponse, TransportResponse))
350
+ # Verify create_user was called with random password
351
+ self.mock_user_manager.create_user.assert_called_once_with(
352
+ "newuser@example.com", "random_pass"
353
+ )
354
+
355
+ @pytest.mark.asyncio
356
+ async def test_sso_callback_login_user_not_found(self) -> None:
357
+ """Test SSO callback for login when user doesn't exist."""
358
+ # Mock request
359
+ 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
365
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
366
+ mock_sso.verify_and_process = AsyncMock(
367
+ return_value=MockUserInfo(email="nonexistent@example.com")
368
+ )
369
+
370
+ with patch.object(self.server, "get_sso", return_value=mock_sso):
371
+ result = await self.server.sso_callback(
372
+ mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
373
+ )
374
+
375
+ assert isinstance(result, (JSONResponse, TransportResponse))
376
+ body = self._get_response_body(result)
377
+
378
+ assert "User not found" in body
379
+
380
+ @pytest.mark.asyncio
381
+ async def test_sso_callback_register_user_already_exists(self) -> None:
382
+ """Test SSO callback for registration when user already exists."""
383
+ # Mock request
384
+ mock_request = Mock(spec=Request)
385
+
386
+ # Mock user manager - user already exists
387
+ self.mock_user_manager.get_user.return_value = {
388
+ "email": "existing@example.com",
389
+ "root_id": str(uuid4()),
390
+ }
391
+
392
+ # Mock GoogleSSO
393
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
394
+ mock_sso.verify_and_process = AsyncMock(
395
+ return_value=MockUserInfo(email="existing@example.com")
396
+ )
397
+
398
+ with patch.object(self.server, "get_sso", return_value=mock_sso):
399
+ result = await self.server.sso_callback(
400
+ mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
401
+ )
402
+
403
+ assert isinstance(result, (JSONResponse, TransportResponse))
404
+ body = self._get_response_body(result)
405
+
406
+ assert "User already exists" in body
407
+
408
+ @pytest.mark.asyncio
409
+ async def test_sso_callback_with_invalid_platform(self) -> None:
410
+ """Test SSO callback with invalid platform."""
411
+ mock_request = Mock(spec=Request)
412
+
413
+ result = await self.server.sso_callback(
414
+ mock_request, "invalid_platform", Operations.LOGIN.value
415
+ )
416
+
417
+ assert isinstance(result, (JSONResponse, TransportResponse))
418
+ body = self._get_response_body(result)
419
+ assert "Invalid platform" in body
420
+
421
+ @pytest.mark.asyncio
422
+ async def test_sso_callback_with_unconfigured_platform(self) -> None:
423
+ """Test SSO callback with unconfigured platform."""
424
+ mock_request = Mock(spec=Request)
425
+
426
+ # Clear supported platforms
427
+ self.server.SUPPORTED_PLATFORMS = {}
428
+
429
+ result = await self.server.sso_callback(
430
+ mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
431
+ )
432
+
433
+ assert isinstance(result, (JSONResponse, TransportResponse))
434
+ body = self._get_response_body(result)
435
+ assert "not configured" in body
436
+
437
+ @pytest.mark.asyncio
438
+ async def test_sso_callback_with_invalid_operation(self) -> None:
439
+ """Test SSO callback with invalid operation."""
440
+ mock_request = Mock(spec=Request)
441
+
442
+ result = await self.server.sso_callback(
443
+ mock_request, Platforms.GOOGLE.value, "invalid_operation"
444
+ )
445
+
446
+ assert isinstance(result, (JSONResponse, TransportResponse))
447
+ body = self._get_response_body(result)
448
+ assert "Invalid operation" in body
449
+
450
+ @pytest.mark.asyncio
451
+ async def test_sso_callback_when_get_sso_fails(self) -> None:
452
+ """Test SSO callback when get_sso returns None."""
453
+ mock_request = Mock(spec=Request)
454
+
455
+ with patch.object(self.server, "get_sso", return_value=None):
456
+ result = await self.server.sso_callback(
457
+ mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
458
+ )
459
+
460
+ assert isinstance(result, (JSONResponse, TransportResponse))
461
+ body = self._get_response_body(result)
462
+ assert "Failed to initialize SSO" in body
463
+
464
+ @pytest.mark.asyncio
465
+ async def test_sso_callback_when_email_not_provided(self) -> None:
466
+ """Test SSO callback when email is not provided by SSO provider."""
467
+ mock_request = Mock(spec=Request)
468
+
469
+ # Mock GoogleSSO with no email
470
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
471
+ mock_user_info = MockUserInfo(email="")
472
+ mock_user_info.email = None # type: ignore
473
+ mock_sso.verify_and_process = AsyncMock(return_value=mock_user_info)
474
+
475
+ with patch.object(self.server, "get_sso", return_value=mock_sso):
476
+ result = await self.server.sso_callback(
477
+ mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
478
+ )
479
+
480
+ assert isinstance(result, (JSONResponse, TransportResponse))
481
+ body = self._get_response_body(result)
482
+
483
+ assert "Email not provided" in body
484
+
485
+ @pytest.mark.asyncio
486
+ async def test_sso_callback_authentication_failure(self) -> None:
487
+ """Test SSO callback when authentication fails."""
488
+ mock_request = Mock(spec=Request)
489
+
490
+ # Mock GoogleSSO that raises exception
491
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
492
+ mock_sso.verify_and_process = AsyncMock(
493
+ side_effect=Exception("Authentication failed")
494
+ )
495
+
496
+ with patch.object(self.server, "get_sso", return_value=mock_sso):
497
+ result = await self.server.sso_callback(
498
+ mock_request, Platforms.GOOGLE.value, Operations.LOGIN.value
499
+ )
500
+
501
+ assert isinstance(result, (JSONResponse, TransportResponse))
502
+ body = self._get_response_body(result)
503
+
504
+ assert "Authentication failed" in body
505
+
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
+ def test_supported_platforms_initialization_with_jac_toml_credentials(self) -> None:
576
+ """Test SUPPORTED_PLATFORMS initialization when credentials are in jac.toml."""
577
+ reset_scale_config()
578
+ # Mock config with credentials (simulating [plugins.scale.sso.google] in jac.toml)
579
+ mock_config = MockScaleConfig(
580
+ {
581
+ "host": "http://localhost:8000/sso",
582
+ "google": {
583
+ "client_id": "toml_test_id",
584
+ "client_secret": "toml_test_secret",
585
+ },
586
+ }
587
+ )
588
+ with patch("jac_scale.serve.get_scale_config", return_value=mock_config):
589
+ server = JacAPIServer(
590
+ module_name="test_module",
591
+ port=8000,
592
+ )
593
+
594
+ assert "google" in server.SUPPORTED_PLATFORMS
595
+ assert server.SUPPORTED_PLATFORMS["google"]["client_id"] == "toml_test_id"
596
+ assert (
597
+ server.SUPPORTED_PLATFORMS["google"]["client_secret"]
598
+ == "toml_test_secret"
599
+ )
600
+
601
+ def test_supported_platforms_initialization_without_jac_toml_credentials(
602
+ self,
603
+ ) -> None:
604
+ """Test SUPPORTED_PLATFORMS initialization when credentials are missing from jac.toml."""
605
+ reset_scale_config()
606
+ # Mock config without credentials (simulating empty sso section in jac.toml)
607
+ mock_config = MockScaleConfig(mock_sso_config_without_credentials())
608
+ with patch("jac_scale.serve.get_scale_config", return_value=mock_config):
609
+ server = JacAPIServer(
610
+ module_name="test_module",
611
+ port=8000,
612
+ )
613
+
614
+ assert "google" not in server.SUPPORTED_PLATFORMS
615
+ assert len(server.SUPPORTED_PLATFORMS) == 0
616
+
617
+ def test_supported_platforms_initialization_with_partial_jac_toml_credentials(
618
+ self,
619
+ ) -> None:
620
+ """Test SUPPORTED_PLATFORMS initialization with only client_id in jac.toml."""
621
+ reset_scale_config()
622
+ # Mock config with partial credentials (only client_id, no secret)
623
+ mock_config = MockScaleConfig(mock_sso_config_partial_credentials())
624
+ with patch("jac_scale.serve.get_scale_config", return_value=mock_config):
625
+ server = JacAPIServer(
626
+ module_name="test_module",
627
+ port=8000,
628
+ )
629
+
630
+ # Should not be added if credentials are incomplete
631
+ assert "google" not in server.SUPPORTED_PLATFORMS
632
+
633
+ @pytest.mark.asyncio
634
+ async def test_sso_callback_login_token_generation(self) -> None:
635
+ """Test that SSO callback generates JWT token on successful login."""
636
+ mock_request = Mock(spec=Request)
637
+
638
+ # Mock user manager
639
+ user_email = "tokentest@example.com"
640
+ self.mock_user_manager.get_user.return_value = {
641
+ "email": user_email,
642
+ "root_id": str(uuid4()),
643
+ }
644
+
645
+ # Mock GoogleSSO
646
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
647
+ mock_sso.verify_and_process = AsyncMock(
648
+ return_value=MockUserInfo(email=user_email)
649
+ )
650
+
651
+ with (
652
+ patch.object(self.server, "get_sso", return_value=mock_sso),
653
+ patch.object(
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
+ )
660
+
661
+ # Verify create_jwt_token was called with correct email
662
+ mock_create_token.assert_called_once_with(user_email)
663
+
664
+ # Verify response contains the token
665
+ assert isinstance(result, (JSONResponse, TransportResponse))
666
+ body = self._get_response_body(result)
667
+
668
+ assert "generated_token" in body
669
+
670
+ @pytest.mark.asyncio
671
+ async def test_sso_callback_register_token_generation(self) -> None:
672
+ """Test that SSO callback generates JWT token on successful registration."""
673
+ mock_request = Mock(spec=Request)
674
+
675
+ user_email = "registertoken@example.com"
676
+
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
+
684
+ # Mock GoogleSSO
685
+ mock_sso = MockGoogleSSO("id", "secret", "uri")
686
+ mock_sso.verify_and_process = AsyncMock(
687
+ return_value=MockUserInfo(email=user_email)
688
+ )
689
+
690
+ with (
691
+ patch.object(self.server, "get_sso", return_value=mock_sso),
692
+ patch.object(
693
+ self.server, "create_jwt_token", return_value="new_user_token"
694
+ ) as mock_create_token,
695
+ patch(
696
+ "jac_scale.serve.generate_random_password",
697
+ return_value="random_pass",
698
+ ),
699
+ ):
700
+ result = await self.server.sso_callback(
701
+ mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
702
+ )
703
+
704
+ # Verify create_jwt_token was called with correct email
705
+ mock_create_token.assert_called_once_with(user_email)
706
+
707
+ # Verify response contains the token
708
+ assert isinstance(result, (JSONResponse, TransportResponse))
709
+ body = self._get_response_body(result)
710
+
711
+ assert "new_user_token" in body