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.
@@ -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 TestJacAPIServerSSO:
113
- """Test SSO functionality in JacAPIServer."""
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
- # 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
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
- """Teardown after each test."""
142
- with contextlib.suppress(BaseException):
143
- del self.server
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 patch("jac_scale.serve.GoogleSSO", return_value=MockGoogleSSO) as mock_sso:
183
- sso = self.server.get_sso(Platforms.GOOGLE.value, Operations.LOGIN.value)
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.server.get_sso("invalid_platform", Operations.LOGIN.value)
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
- 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)
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("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"
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.server, "get_sso", return_value=MockGoogleSSO("id", "secret", "uri")
244
+ self.user_manager,
245
+ "get_sso",
246
+ return_value=MockGoogleSSO("id", "secret", "uri"),
224
247
  ):
225
- result = await self.server.sso_initiate(
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.server.sso_initiate(
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.server.SUPPORTED_PLATFORMS = {}
270
+ self.user_manager.SUPPORTED_PLATFORMS = {}
249
271
 
250
- result = await self.server.sso_initiate(
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.server.sso_initiate(
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.server, "get_sso", return_value=None):
273
- result = await self.server.sso_initiate(
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
- # Mock user manager
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.server, "get_sso", return_value=mock_sso),
319
+ patch.object(self.user_manager, "get_sso", return_value=mock_sso),
301
320
  patch.object(
302
- self.server, "create_jwt_token", return_value="mock_jwt_token"
321
+ self.user_manager, "create_jwt_token", return_value="mock_jwt_token"
303
322
  ),
304
323
  ):
305
- result = await self.server.sso_callback(
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
- # 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 = {
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.server, "get_sso", return_value=mock_sso),
352
+ patch.object(self.user_manager, "get_sso", return_value=mock_sso),
337
353
  patch.object(
338
- self.server, "create_jwt_token", return_value="mock_jwt_token"
354
+ self.user_manager, "create_jwt_token", return_value="mock_jwt_token"
339
355
  ),
340
356
  patch(
341
- "jac_scale.serve.generate_random_password",
357
+ "jac_scale.user_manager.generate_random_password",
342
358
  return_value="random_pass",
343
359
  ),
344
360
  ):
345
- result = await self.server.sso_callback(
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
- # Verify create_user was called with random password
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
- with patch.object(self.server, "get_sso", return_value=mock_sso):
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
- with patch.object(self.server, "get_sso", return_value=mock_sso):
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.server.sso_callback(
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
- # Clear supported platforms
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
- with patch.object(self.server, "get_sso", return_value=None):
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
- with patch.object(self.server, "get_sso", return_value=mock_sso):
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
- with patch.object(self.server, "get_sso", return_value=mock_sso):
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
- 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"
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
- server.SUPPORTED_PLATFORMS["google"]["client_secret"]
598
- == "toml_test_secret"
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.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
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
- 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
- )
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
- # Should not be added if credentials are incomplete
631
- assert "google" not in server.SUPPORTED_PLATFORMS
554
+ # Link first time
555
+ self.user_manager.link_sso_account(
556
+ "user1", "google", "ext_123", "test@google.com"
557
+ )
632
558
 
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)
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
- # 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
- }
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
- # Mock GoogleSSO
646
- mock_sso = MockGoogleSSO("id", "secret", "uri")
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
- 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
- )
584
+ # Unlink
585
+ result = self.user_manager.unlink_sso_account("user1", "google")
586
+ assert result.get("message") == "SSO account unlinked successfully"
660
587
 
661
- # Verify create_jwt_token was called with correct email
662
- mock_create_token.assert_called_once_with(user_email)
588
+ # Verify empty
589
+ accounts = self.user_manager.get_sso_accounts("user1")
590
+ assert len(accounts) == 0
663
591
 
664
- # Verify response contains the token
665
- assert isinstance(result, (JSONResponse, TransportResponse))
666
- body = self._get_response_body(result)
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
- assert "generated_token" in body
601
+ self.user_manager.link_sso_account(
602
+ "user1", "google", "ext_123", "test@google.com"
603
+ )
669
604
 
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)
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
- user_email = "registertoken@example.com"
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
- # Mock GoogleSSO
685
- mock_sso = MockGoogleSSO("id", "secret", "uri")
686
- mock_sso.verify_and_process = AsyncMock(
687
- return_value=MockUserInfo(email=user_email)
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.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,
626
+ patch("jac_scale.serve.get_scale_config", return_value=self.mock_config),
695
627
  patch(
696
- "jac_scale.serve.generate_random_password",
697
- return_value="random_pass",
628
+ "jaclang.pycore.runtime.JacRuntimeInterface.get_user_manager",
629
+ return_value=self.mock_user_manager,
698
630
  ),
699
631
  ):
700
- result = await self.server.sso_callback(
701
- mock_request, Platforms.GOOGLE.value, Operations.REGISTER.value
702
- )
632
+ self.server = JacAPIServer(module_name="test_module", port=8000)
703
633
 
704
- # Verify create_jwt_token was called with correct email
705
- mock_create_token.assert_called_once_with(user_email)
634
+ self.server.server = self.mock_server_impl
706
635
 
707
- # Verify response contains the token
708
- assert isinstance(result, (JSONResponse, TransportResponse))
709
- body = self._get_response_body(result)
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
- assert "new_user_token" in body
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"