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.
- jac_scale/__init__.py +0 -0
- jac_scale/abstractions/config/app_config.jac +30 -0
- jac_scale/abstractions/config/base_config.jac +26 -0
- jac_scale/abstractions/database_provider.jac +51 -0
- jac_scale/abstractions/deployment_target.jac +64 -0
- jac_scale/abstractions/image_registry.jac +54 -0
- jac_scale/abstractions/logger.jac +20 -0
- jac_scale/abstractions/models/deployment_result.jac +27 -0
- jac_scale/abstractions/models/resource_status.jac +38 -0
- jac_scale/config_loader.jac +31 -0
- jac_scale/context.jac +14 -0
- jac_scale/factories/database_factory.jac +43 -0
- jac_scale/factories/deployment_factory.jac +43 -0
- jac_scale/factories/registry_factory.jac +32 -0
- jac_scale/factories/utility_factory.jac +34 -0
- jac_scale/impl/config_loader.impl.jac +131 -0
- jac_scale/impl/context.impl.jac +24 -0
- jac_scale/impl/memory_hierarchy.main.impl.jac +63 -0
- jac_scale/impl/memory_hierarchy.mongo.impl.jac +239 -0
- jac_scale/impl/memory_hierarchy.redis.impl.jac +186 -0
- jac_scale/impl/serve.impl.jac +1785 -0
- jac_scale/jserver/__init__.py +0 -0
- jac_scale/jserver/impl/jfast_api.impl.jac +731 -0
- jac_scale/jserver/impl/jserver.impl.jac +79 -0
- jac_scale/jserver/jfast_api.jac +162 -0
- jac_scale/jserver/jserver.jac +101 -0
- jac_scale/memory_hierarchy.jac +138 -0
- jac_scale/plugin.jac +218 -0
- jac_scale/plugin_config.jac +175 -0
- jac_scale/providers/database/kubernetes_mongo.jac +137 -0
- jac_scale/providers/database/kubernetes_redis.jac +110 -0
- jac_scale/providers/registry/dockerhub.jac +64 -0
- jac_scale/serve.jac +118 -0
- jac_scale/targets/kubernetes/kubernetes_config.jac +215 -0
- jac_scale/targets/kubernetes/kubernetes_target.jac +841 -0
- jac_scale/targets/kubernetes/utils/kubernetes_utils.impl.jac +519 -0
- jac_scale/targets/kubernetes/utils/kubernetes_utils.jac +85 -0
- jac_scale/tests/__init__.py +0 -0
- jac_scale/tests/conftest.py +29 -0
- jac_scale/tests/fixtures/test_api.jac +159 -0
- jac_scale/tests/fixtures/todo_app.jac +68 -0
- jac_scale/tests/test_abstractions.py +88 -0
- jac_scale/tests/test_deploy_k8s.py +265 -0
- jac_scale/tests/test_examples.py +484 -0
- jac_scale/tests/test_factories.py +149 -0
- jac_scale/tests/test_file_upload.py +444 -0
- jac_scale/tests/test_k8s_utils.py +156 -0
- jac_scale/tests/test_memory_hierarchy.py +247 -0
- jac_scale/tests/test_serve.py +1835 -0
- jac_scale/tests/test_sso.py +711 -0
- jac_scale/utilities/loggers/standard_logger.jac +40 -0
- jac_scale/utils.jac +16 -0
- jac_scale-0.1.1.dist-info/METADATA +658 -0
- jac_scale-0.1.1.dist-info/RECORD +57 -0
- jac_scale-0.1.1.dist-info/WHEEL +5 -0
- jac_scale-0.1.1.dist-info/entry_points.txt +3 -0
- 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
|