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,1835 @@
1
+ """Test for jac-scale serve command and REST API server."""
2
+
3
+ import contextlib
4
+ import gc
5
+ import glob
6
+ import socket
7
+ import subprocess
8
+ import time
9
+ import uuid
10
+ from datetime import UTC, datetime, timedelta
11
+ from pathlib import Path
12
+ from typing import Any, cast
13
+
14
+ import jwt as pyjwt
15
+ import pytest
16
+ import requests
17
+
18
+
19
+ def get_free_port() -> int:
20
+ """Get a free port by binding to port 0 and releasing it."""
21
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
22
+ s.bind(("", 0))
23
+ s.listen(1)
24
+ port = s.getsockname()[1]
25
+ return port
26
+
27
+
28
+ class TestJacScaleServe:
29
+ """Test jac-scale serve REST API functionality."""
30
+
31
+ # Class attributes with type annotations
32
+ fixtures_dir: Path
33
+ test_file: Path
34
+ port: int
35
+ base_url: str
36
+ server_process: subprocess.Popen[str] | None = None
37
+
38
+ @classmethod
39
+ def setup_class(cls) -> None:
40
+ """Set up test class - runs once for all tests."""
41
+ cls.fixtures_dir = Path(__file__).parent / "fixtures"
42
+ cls.test_file = cls.fixtures_dir / "test_api.jac"
43
+
44
+ # Ensure fixture file exists
45
+ if not cls.test_file.exists():
46
+ raise FileNotFoundError(f"Test fixture not found: {cls.test_file}")
47
+
48
+ # Use dynamically allocated free port
49
+ cls.port = get_free_port()
50
+ cls.base_url = f"http://localhost:{cls.port}"
51
+
52
+ # Clean up any existing database files before starting
53
+ cls._cleanup_db_files()
54
+
55
+ # Start the server process
56
+ cls.server_process = None
57
+ cls._start_server()
58
+
59
+ @classmethod
60
+ def teardown_class(cls) -> None:
61
+ """Tear down test class - runs once after all tests."""
62
+ # Stop server process
63
+ if cls.server_process:
64
+ cls.server_process.terminate()
65
+ try:
66
+ cls.server_process.wait(timeout=5)
67
+ except subprocess.TimeoutExpired:
68
+ cls.server_process.kill()
69
+ cls.server_process.wait()
70
+
71
+ # Give the server a moment to fully release file handles
72
+ time.sleep(0.5)
73
+ # Run garbage collection to clean up lingering socket objects
74
+ gc.collect()
75
+
76
+ # Clean up database files
77
+ cls._cleanup_db_files()
78
+
79
+ @classmethod
80
+ def _start_server(cls) -> None:
81
+ """Start the jac-scale server in a subprocess."""
82
+ import sys
83
+
84
+ # Get the jac executable from the same directory as the current Python interpreter
85
+ jac_executable = Path(sys.executable).parent / "jac"
86
+
87
+ # Build the command to start the server
88
+ # Use just the filename and set cwd to fixtures directory
89
+ # This is required for proper bytecode caching and module resolution
90
+ cmd = [
91
+ str(jac_executable),
92
+ "start",
93
+ cls.test_file.name,
94
+ "--port",
95
+ str(cls.port),
96
+ ]
97
+
98
+ # Start the server process with cwd set to fixtures directory
99
+ cls.server_process = subprocess.Popen(
100
+ cmd,
101
+ stdout=subprocess.PIPE,
102
+ stderr=subprocess.PIPE,
103
+ text=True,
104
+ cwd=str(cls.fixtures_dir),
105
+ )
106
+
107
+ # Wait for server to be ready
108
+ max_attempts = 50
109
+ server_ready = False
110
+
111
+ for _ in range(max_attempts):
112
+ # Check if process has died
113
+ if cls.server_process.poll() is not None:
114
+ # Process has terminated, get output
115
+ stdout, stderr = cls.server_process.communicate()
116
+ raise RuntimeError(
117
+ f"Server process terminated unexpectedly.\n"
118
+ f"STDOUT: {stdout}\nSTDERR: {stderr}"
119
+ )
120
+
121
+ try:
122
+ # Try to connect to any endpoint to verify server is up
123
+ # Use /docs which should exist in FastAPI
124
+ response = requests.get(f"{cls.base_url}/docs", timeout=2)
125
+ if response.status_code in (200, 404): # Server is responding
126
+ print(f"Server started successfully on port {cls.port}")
127
+ server_ready = True
128
+ break
129
+ except (requests.ConnectionError, requests.Timeout):
130
+ time.sleep(2)
131
+
132
+ # If we get here and server is not ready, it failed to start
133
+ if not server_ready:
134
+ # Try to terminate the process
135
+ cls.server_process.terminate()
136
+ try:
137
+ stdout, stderr = cls.server_process.communicate(timeout=2)
138
+ except subprocess.TimeoutExpired:
139
+ cls.server_process.kill()
140
+ stdout, stderr = cls.server_process.communicate()
141
+
142
+ raise RuntimeError(
143
+ f"Server failed to start after {max_attempts} attempts.\n"
144
+ f"STDOUT: {stdout}\nSTDERR: {stderr}"
145
+ )
146
+
147
+ @classmethod
148
+ def _cleanup_db_files(cls) -> None:
149
+ """Delete SQLite database files and legacy shelf files."""
150
+ import shutil
151
+
152
+ # Clean up SQLite database files (WAL mode creates -wal and -shm files)
153
+ for pattern in [
154
+ "*.db",
155
+ "*.db-wal",
156
+ "*.db-shm",
157
+ # Legacy shelf files
158
+ "anchor_store.db.dat",
159
+ "anchor_store.db.bak",
160
+ "anchor_store.db.dir",
161
+ ]:
162
+ for db_file in glob.glob(pattern):
163
+ with contextlib.suppress(Exception):
164
+ Path(db_file).unlink()
165
+
166
+ # Clean up database files in fixtures directory
167
+ for pattern in ["*.db", "*.db-wal", "*.db-shm"]:
168
+ for db_file in glob.glob(str(cls.fixtures_dir / pattern)):
169
+ with contextlib.suppress(Exception):
170
+ Path(db_file).unlink()
171
+
172
+ # Clean up .jac directory created during serve
173
+ client_build_dir = cls.fixtures_dir / ".jac"
174
+ if client_build_dir.exists():
175
+ with contextlib.suppress(Exception):
176
+ shutil.rmtree(client_build_dir)
177
+
178
+ @staticmethod
179
+ def _extract_transport_response_data(
180
+ json_response: dict[str, Any] | list[Any],
181
+ ) -> dict[str, Any] | list[Any]:
182
+ """Extract data from TransportResponse envelope format.
183
+
184
+ Handles both success and error responses.
185
+ """
186
+ # Handle jac-scale's tuple response format [status, body]
187
+ if isinstance(json_response, list) and len(json_response) == 2:
188
+ body: dict[str, Any] = json_response[1]
189
+ json_response = body
190
+
191
+ # Handle TransportResponse envelope format
192
+ # If response has 'ok', 'type', 'data', 'error' keys, extract data/error
193
+ if (
194
+ isinstance(json_response, dict)
195
+ and "ok" in json_response
196
+ and "data" in json_response
197
+ ):
198
+ if json_response.get("ok") and json_response.get("data") is not None:
199
+ # Success case: return the data field
200
+ return json_response["data"]
201
+ elif not json_response.get("ok") and json_response.get("error"):
202
+ # Error case: return error info in a format tests expect
203
+ error_info = json_response["error"]
204
+ result: dict[str, Any] = {
205
+ "error": error_info.get("message", "Unknown error")
206
+ }
207
+ if "code" in error_info:
208
+ result["error_code"] = error_info["code"]
209
+ if "details" in error_info:
210
+ result["error_details"] = error_info["details"]
211
+ return result
212
+
213
+ # FastAPI validation errors (422) have "detail" field - return as-is
214
+ # These come from Pydantic validation before our endpoint is called
215
+ return json_response
216
+
217
+ def _request(
218
+ self,
219
+ method: str,
220
+ path: str,
221
+ data: dict[str, Any] | None = None,
222
+ token: str | None = None,
223
+ timeout: int = 5,
224
+ max_retries: int = 60,
225
+ retry_interval: float = 2.0,
226
+ ) -> dict[str, Any]:
227
+ """Make HTTP request to server and return JSON response.
228
+
229
+ Retries on 503 Service Unavailable responses.
230
+ """
231
+ url = f"{self.base_url}{path}"
232
+ headers = {"Content-Type": "application/json"}
233
+
234
+ if token:
235
+ headers["Authorization"] = f"Bearer {token}"
236
+
237
+ response = None
238
+ for attempt in range(max_retries):
239
+ response = requests.request(
240
+ method=method,
241
+ url=url,
242
+ json=data,
243
+ headers=headers,
244
+ timeout=timeout,
245
+ )
246
+
247
+ if response.status_code == 503:
248
+ print(
249
+ f"[DEBUG] {path} returned 503, retrying ({attempt + 1}/{max_retries})..."
250
+ )
251
+ time.sleep(retry_interval)
252
+ continue
253
+
254
+ break
255
+
256
+ assert response is not None, "No response received"
257
+ json_response: Any = response.json()
258
+ return self._extract_transport_response_data(json_response) # type: ignore[return-value]
259
+
260
+ def _create_expired_token(self, username: str, days_ago: int = 1) -> str:
261
+ """Create an expired JWT token for testing."""
262
+ # Use the same secret as the server (default)
263
+ secret = "supersecretkey"
264
+ algorithm = "HS256"
265
+
266
+ past_time = datetime.now(UTC) - timedelta(days=days_ago)
267
+ payload = {
268
+ "username": username,
269
+ "exp": past_time + timedelta(hours=1), # Expired 1 hour after past_time
270
+ "iat": past_time,
271
+ }
272
+ return pyjwt.encode(payload, secret, algorithm=algorithm)
273
+
274
+ def _create_very_old_token(self, username: str, days_ago: int = 15) -> str:
275
+ """Create a token that's too old to refresh."""
276
+ secret = "supersecretkey"
277
+ algorithm = "HS256"
278
+
279
+ past_time = datetime.now(UTC) - timedelta(days=days_ago)
280
+ payload = {
281
+ "username": username,
282
+ "exp": past_time + timedelta(hours=1),
283
+ "iat": past_time,
284
+ }
285
+ return pyjwt.encode(payload, secret, algorithm=algorithm)
286
+
287
+ def test_server_root_endpoint(self) -> None:
288
+ """Test that the server is running and FastAPI docs are available."""
289
+ # Check that /docs endpoint exists (FastAPI auto-generated docs)
290
+ response = requests.get(f"{self.base_url}/docs", timeout=5)
291
+ assert response.status_code == 200
292
+
293
+ def test_user_creation(self) -> None:
294
+ """Test user creation endpoint."""
295
+ result = self._request(
296
+ "POST",
297
+ "/user/register",
298
+ {"username": "testuser1", "password": "testpass123"},
299
+ )
300
+
301
+ assert "username" in result
302
+ assert "token" in result
303
+ assert "root_id" in result
304
+ assert result["username"] == "testuser1"
305
+
306
+ def test_user_login(self) -> None:
307
+ """Test user login endpoint."""
308
+ # Create user first
309
+ create_result = self._request(
310
+ "POST",
311
+ "/user/register",
312
+ {"username": "loginuser", "password": "loginpass"},
313
+ )
314
+
315
+ # Login with correct credentials
316
+ login_result = self._request(
317
+ "POST",
318
+ "/user/login",
319
+ {"username": "loginuser", "password": "loginpass"},
320
+ )
321
+
322
+ assert "token" in login_result
323
+ assert login_result["username"] == "loginuser"
324
+ assert login_result["root_id"] == create_result["root_id"]
325
+
326
+ def test_user_login_wrong_password(self) -> None:
327
+ """Test login fails with wrong password."""
328
+ # Create user
329
+ self._request(
330
+ "POST",
331
+ "/user/register",
332
+ {"username": "failuser", "password": "correctpass"},
333
+ )
334
+
335
+ # Try to login with wrong password
336
+ login_result = self._request(
337
+ "POST",
338
+ "/user/login",
339
+ {"username": "failuser", "password": "wrongpass"},
340
+ )
341
+
342
+ assert "error" in login_result
343
+
344
+ def test_refresh_token_with_missing_token(self) -> None:
345
+ """Test refresh endpoint without token parameter."""
346
+ refresh_result = self._request(
347
+ "POST",
348
+ "/user/refresh-token",
349
+ {},
350
+ )
351
+
352
+ # Case 1: FastAPI Automatic Validation (422 Unprocessable Entity)
353
+ # This happens because 'token' is missing from the body entirely.
354
+ if "detail" in refresh_result:
355
+ assert isinstance(refresh_result["detail"], list)
356
+ error_entry = refresh_result["detail"][0]
357
+ assert error_entry["loc"] == ["body", "token"]
358
+ assert error_entry["type"] == "missing"
359
+
360
+ # Case 2: Custom Logic Error
361
+ # This handles cases where your code manually returns an error (if you bypass Pydantic).
362
+ else:
363
+ assert "error" in refresh_result
364
+ assert refresh_result["error"] in [
365
+ "Token is required",
366
+ "Invalid or expired token",
367
+ ]
368
+
369
+ def test_refresh_token_with_bearer_prefix(self) -> None:
370
+ """Test refreshing token with 'Bearer ' prefix."""
371
+ # Create user and get token
372
+ create_result = self._request(
373
+ "POST",
374
+ "/user/register",
375
+ {"username": "refresh_bearer", "password": "password123"},
376
+ )
377
+ original_token = create_result["token"]
378
+
379
+ # Refresh with Bearer prefix
380
+ refresh_result = self._request(
381
+ "POST",
382
+ "/user/refresh-token",
383
+ {"token": f"Bearer {original_token}"},
384
+ )
385
+
386
+ assert "token" in refresh_result
387
+ assert "message" in refresh_result
388
+ assert refresh_result["message"] == "Token refreshed successfully"
389
+
390
+ def test_refresh_token_with_empty_token(self) -> None:
391
+ """Test refresh endpoint with empty token."""
392
+ refresh_result = self._request(
393
+ "POST",
394
+ "/user/refresh-token",
395
+ {"token": ""},
396
+ )
397
+
398
+ assert "error" in refresh_result
399
+ assert refresh_result["error"] == "Token is required"
400
+
401
+ def test_refresh_token_with_invalid_token(self) -> None:
402
+ """Test refreshing with completely invalid token."""
403
+ refresh_result = self._request(
404
+ "POST",
405
+ "/user/refresh-token",
406
+ {"token": "invalid.token.here"},
407
+ )
408
+
409
+ assert "error" in refresh_result
410
+ assert refresh_result["error"] == "Invalid or expired token"
411
+
412
+ def test_refresh_token_with_malformed_token(self) -> None:
413
+ """Test refreshing with malformed JWT token."""
414
+ refresh_result = self._request(
415
+ "POST",
416
+ "/user/refresh-token",
417
+ {"token": "not.a.jwt"},
418
+ )
419
+
420
+ assert "error" in refresh_result
421
+ assert refresh_result["error"] == "Invalid or expired token"
422
+
423
+ def test_refresh_token_too_old(self) -> None:
424
+ """Test refreshing with token older than refresh window."""
425
+ # Create user first
426
+ self._request(
427
+ "POST",
428
+ "/user/register",
429
+ {"username": "refresh_old", "password": "password123"},
430
+ )
431
+
432
+ # Create a very old token (15 days old, beyond refresh window)
433
+ very_old_token = self._create_very_old_token("refresh_old", days_ago=15)
434
+
435
+ # Try to refresh the very old token
436
+ refresh_result = self._request(
437
+ "POST",
438
+ "/user/refresh-token",
439
+ {"token": very_old_token},
440
+ )
441
+
442
+ assert "error" in refresh_result
443
+ assert refresh_result["error"] == "Invalid or expired token"
444
+
445
+ def test_refresh_token_with_nonexistent_user(self) -> None:
446
+ """Test refreshing token for user that doesn't exist."""
447
+ # Create token for non-existent user
448
+ fake_token = self._create_expired_token("nonexistent", days_ago=1)
449
+
450
+ refresh_result = self._request(
451
+ "POST",
452
+ "/user/refresh-token",
453
+ {"token": fake_token},
454
+ )
455
+
456
+ assert "error" in refresh_result
457
+ assert refresh_result["error"] == "Invalid or expired token"
458
+
459
+ def test_refresh_token_multiple_times(self) -> None:
460
+ """Test refreshing token multiple times in succession."""
461
+ # Create user and get initial token
462
+ create_result = self._request(
463
+ "POST",
464
+ "/user/register",
465
+ {"username": "refresh_multi", "password": "password123"},
466
+ )
467
+ token1 = create_result["token"]
468
+
469
+ # First refresh
470
+ refresh_result1 = self._request(
471
+ "POST",
472
+ "/user/refresh-token",
473
+ {"token": token1},
474
+ )
475
+ token2 = refresh_result1["token"]
476
+ assert token2 != token1
477
+
478
+ # Second refresh
479
+ refresh_result2 = self._request(
480
+ "POST",
481
+ "/user/refresh-token",
482
+ {"token": token2},
483
+ )
484
+ token3 = refresh_result2["token"]
485
+ assert token3 != token2
486
+ assert token3 != token1
487
+
488
+ def test_refresh_token_preserves_username(self) -> None:
489
+ """Test that refreshed token contains correct username."""
490
+ # Create user
491
+ username = "refresh_preserve"
492
+ create_result = self._request(
493
+ "POST",
494
+ "/user/register",
495
+ {"username": username, "password": "password123"},
496
+ )
497
+ original_token = create_result["token"]
498
+
499
+ # Refresh token
500
+ refresh_result = self._request(
501
+ "POST",
502
+ "/user/refresh-token",
503
+ {"token": original_token},
504
+ )
505
+ new_token = refresh_result["token"]
506
+
507
+ # Decode both tokens and verify username is preserved
508
+ secret = "supersecretkey"
509
+ algorithm = "HS256"
510
+
511
+ original_payload = pyjwt.decode(original_token, secret, algorithms=[algorithm])
512
+ new_payload = pyjwt.decode(new_token, secret, algorithms=[algorithm])
513
+
514
+ assert original_payload["username"] == username
515
+ assert new_payload["username"] == username
516
+ assert original_payload["username"] == new_payload["username"]
517
+
518
+ @pytest.mark.xfail(reason="possible issue with user.json", strict=False)
519
+ def test_refresh_token_updates_expiration(self) -> None:
520
+ """Test that refreshed token has updated expiration time."""
521
+ # Create user and get token
522
+ create_result = self._request(
523
+ "POST",
524
+ "/user/register",
525
+ {"username": "refresh_exp", "password": "password123"},
526
+ )
527
+ original_token = create_result["token"]
528
+
529
+ # Refresh token
530
+ refresh_result = self._request(
531
+ "POST",
532
+ "/user/refresh-token",
533
+ {"token": original_token},
534
+ )
535
+ new_token = refresh_result["token"]
536
+
537
+ # Decode tokens and compare expiration times
538
+ secret = "supersecretkey"
539
+ algorithm = "HS256"
540
+
541
+ original_payload = pyjwt.decode(original_token, secret, algorithms=[algorithm])
542
+ new_payload = pyjwt.decode(new_token, secret, algorithms=[algorithm])
543
+
544
+ # New token should have later expiration time
545
+ assert new_payload["exp"] > original_payload["exp"]
546
+ assert new_payload["iat"] > original_payload["iat"]
547
+
548
+ def test_refresh_endpoint_in_openapi_docs(self) -> None:
549
+ """Test that refresh endpoint appears in OpenAPI documentation."""
550
+ response = requests.get(f"{self.base_url}/openapi.json", timeout=5)
551
+ assert response.status_code == 200
552
+
553
+ openapi_spec = response.json()
554
+ paths = openapi_spec.get("paths", {})
555
+
556
+ # Check that refresh endpoint is documented
557
+ assert "/user/refresh-token" in paths
558
+ refresh_endpoint = paths["/user/refresh-token"]
559
+ assert "post" in refresh_endpoint
560
+
561
+ # Check endpoint metadata
562
+ post_spec = refresh_endpoint["post"]
563
+ assert post_spec["summary"] == "Refresh JWT token"
564
+ assert "User APIs" in post_spec["tags"]
565
+
566
+ def test_call_function_add_numbers(self) -> None:
567
+ """Test calling the add_numbers function."""
568
+ # Create user
569
+ create_result = self._request(
570
+ "POST",
571
+ "/user/register",
572
+ {"username": "adduser", "password": "pass"},
573
+ )
574
+ token = create_result["token"]
575
+
576
+ # Call add_numbers
577
+ result = self._request(
578
+ "POST",
579
+ "/function/add_numbers",
580
+ {"a": 10, "b": 25},
581
+ token=token,
582
+ )
583
+
584
+ assert "result" in result
585
+ assert result["result"] == 35
586
+
587
+ def test_call_function_greet(self) -> None:
588
+ """Test calling the greet function."""
589
+ # Create user
590
+ create_result = self._request(
591
+ "POST",
592
+ "/user/register",
593
+ {"username": "greetuser", "password": "pass"},
594
+ )
595
+ token = create_result["token"]
596
+
597
+ # Call greet with name
598
+ result = self._request(
599
+ "POST",
600
+ "/function/greet",
601
+ {"name": "Alice"},
602
+ token=token,
603
+ )
604
+
605
+ assert "result" in result
606
+ assert result["result"] == "Hello, Alice!"
607
+
608
+ def test_call_function_with_defaults(self) -> None:
609
+ """Test calling function with default parameters."""
610
+ # Create user with unique username to avoid conflicts
611
+ username = f"defuser_{uuid.uuid4().hex[:8]}"
612
+ create_result = self._request(
613
+ "POST",
614
+ "/user/register",
615
+ {"username": username, "password": "pass"},
616
+ )
617
+ assert "token" in create_result, f"Registration failed: {create_result}"
618
+ token = create_result["token"]
619
+
620
+ # Call greet without name (should use default)
621
+ result = self._request(
622
+ "POST",
623
+ "/function/greet",
624
+ {"args": {}},
625
+ token=token,
626
+ )
627
+
628
+ assert "result" in result
629
+ assert result["result"] == "Hello, World!"
630
+
631
+ @pytest.mark.xfail(reason="possible issue with user.json", strict=False)
632
+ def test_spawn_walker_create_task(self) -> None:
633
+ """Test spawning a CreateTask walker."""
634
+ # Create user
635
+ create_result = self._request(
636
+ "POST",
637
+ "/user/register",
638
+ {"username": "spawnuser", "password": "pass"},
639
+ )
640
+ token = create_result["token"]
641
+
642
+ # Spawn CreateTask walker
643
+ result = self._request(
644
+ "POST",
645
+ "/walker/CreateTask",
646
+ {"title": "Test Task", "priority": 2},
647
+ token=token,
648
+ )
649
+
650
+ assert "result" in result
651
+ assert "reports" in result
652
+
653
+ @pytest.mark.xfail(reason="possible issue with user.json", strict=False)
654
+ def test_user_isolation(self) -> None:
655
+ """Test that users have isolated graph spaces."""
656
+ # Use unique emails to avoid conflicts with previous test runs
657
+ unique_id = uuid.uuid4().hex[:8]
658
+ username1 = f"isolate1_{unique_id}"
659
+ username2 = f"isolate2_{unique_id}"
660
+
661
+ # Create two users
662
+ user1 = self._request(
663
+ "POST",
664
+ "/user/register",
665
+ {"username": username1, "password": "pass1"},
666
+ )
667
+ user2 = self._request(
668
+ "POST",
669
+ "/user/register",
670
+ {"username": username2, "password": "pass2"},
671
+ )
672
+
673
+ print(f"user1: {user1}")
674
+ print(f"user2: {user2}")
675
+ # Both users should be created successfully (no error, has root_id)
676
+ assert "error" not in user1, f"user1 creation failed: {user1}"
677
+ assert "error" not in user2, f"user2 creation failed: {user2}"
678
+ assert "root_id" in user1, f"user1 missing root_id: {user1}"
679
+ assert "root_id" in user2, f"user2 missing root_id: {user2}"
680
+ # Users should have different root IDs
681
+ assert user1["root_id"] != user2["root_id"]
682
+
683
+ def test_invalid_function(self) -> None:
684
+ """Test calling nonexistent function."""
685
+ # Create user
686
+ create_result = self._request(
687
+ "POST",
688
+ "/user/register",
689
+ {"username": "invalidfunc", "password": "pass"},
690
+ )
691
+ token = create_result["token"]
692
+
693
+ # Try to call nonexistent function
694
+ result = self._request(
695
+ "POST",
696
+ "/function/nonexistent",
697
+ {"args": {}},
698
+ token=token,
699
+ )
700
+
701
+ assert "Method Not Allowed" in result["detail"]
702
+
703
+ def test_invalid_walker(self) -> None:
704
+ """Test spawning nonexistent walker."""
705
+ # Create user
706
+ create_result = self._request(
707
+ "POST",
708
+ "/user/register",
709
+ {"username": "invalidwalk", "password": "pass"},
710
+ )
711
+ token = create_result["token"]
712
+
713
+ # Try to spawn nonexistent walker
714
+ result = self._request(
715
+ "POST",
716
+ "/walker/NonExistentWalker",
717
+ {"fields": {}},
718
+ token=token,
719
+ )
720
+
721
+ assert "Method Not Allowed" in result["detail"]
722
+
723
+ def test_multiply_function(self) -> None:
724
+ """Test calling the multiply function (jac-scale specific test)."""
725
+ # Create user
726
+ create_result = self._request(
727
+ "POST",
728
+ "/user/register",
729
+ {"username": "multuser", "password": "pass"},
730
+ )
731
+ token = create_result["token"]
732
+
733
+ # Call multiply
734
+ result = self._request(
735
+ "POST",
736
+ "/function/multiply",
737
+ {"x": 7, "y": 8},
738
+ token=token,
739
+ )
740
+
741
+ assert "result" in result
742
+ assert result["result"] == 56
743
+
744
+ def test_status_code_user_register_201_success(self) -> None:
745
+ """Test POST /user/register returns 201 on successful registration."""
746
+ response = requests.post(
747
+ f"{self.base_url}/user/register",
748
+ json={"username": "status201", "password": "password123"},
749
+ timeout=5,
750
+ )
751
+ assert response.status_code == 201
752
+ data = cast(
753
+ dict[str, Any], self._extract_transport_response_data(response.json())
754
+ )
755
+ assert "token" in data
756
+ assert "username" in data
757
+ assert data["username"] == "status201"
758
+
759
+ def test_status_code_user_register_400_already_exists(self) -> None:
760
+ """Test POST /user/register returns 400 when user already exists."""
761
+ username = "status400exists"
762
+ # Create user first
763
+ requests.post(
764
+ f"{self.base_url}/user/register",
765
+ json={"username": username, "password": "password123"},
766
+ timeout=5,
767
+ )
768
+
769
+ # Try to create again
770
+ response = requests.post(
771
+ f"{self.base_url}/user/register",
772
+ json={"username": username, "password": "password123"},
773
+ timeout=5,
774
+ )
775
+ assert response.status_code == 400
776
+ data = cast(
777
+ dict[str, Any], self._extract_transport_response_data(response.json())
778
+ )
779
+ assert "error" in data
780
+
781
+ def test_status_code_user_login_200_success(self) -> None:
782
+ """Test POST /user/login returns 200 on successful login."""
783
+ username = "status200login"
784
+ # Create user first
785
+ requests.post(
786
+ f"{self.base_url}/user/register",
787
+ json={"username": username, "password": "password123"},
788
+ timeout=5,
789
+ )
790
+
791
+ # Login
792
+ response = requests.post(
793
+ f"{self.base_url}/user/login",
794
+ json={"username": username, "password": "password123"},
795
+ timeout=5,
796
+ )
797
+ assert response.status_code == 200
798
+ data = self._extract_transport_response_data(response.json())
799
+ assert "token" in data
800
+
801
+ def test_status_code_user_login_400_missing_credentials(self) -> None:
802
+ """Test POST /user/login returns 400/422 when username or password is missing."""
803
+ # Missing password - FastAPI returns 422 for validation errors
804
+ response = requests.post(
805
+ f"{self.base_url}/user/login",
806
+ json={"username": "test"},
807
+ timeout=5,
808
+ )
809
+ assert response.status_code in [400, 422] # 422 from FastAPI validation
810
+ data = cast(
811
+ dict[str, Any], self._extract_transport_response_data(response.json())
812
+ )
813
+ # Either custom error or FastAPI validation error
814
+ assert "error" in data or "detail" in data
815
+
816
+ # Missing username
817
+ response = requests.post(
818
+ f"{self.base_url}/user/login",
819
+ json={"password": "password123"},
820
+ timeout=5,
821
+ )
822
+ assert response.status_code in [400, 422]
823
+
824
+ # Missing both
825
+ response = requests.post(
826
+ f"{self.base_url}/user/login",
827
+ json={},
828
+ timeout=5,
829
+ )
830
+ assert response.status_code in [400, 422]
831
+
832
+ # Empty string values - should trigger custom 400 validation
833
+ response = requests.post(
834
+ f"{self.base_url}/user/login",
835
+ json={"username": "", "password": "password123"},
836
+ timeout=5,
837
+ )
838
+ assert response.status_code == 400
839
+ data = cast(
840
+ dict[str, Any], self._extract_transport_response_data(response.json())
841
+ )
842
+ assert data["error"] == "Username and password required"
843
+
844
+ def test_status_code_user_login_401_invalid_credentials(self) -> None:
845
+ """Test POST /user/login returns 401 for invalid credentials."""
846
+ username = "status401login"
847
+ # Create user
848
+ requests.post(
849
+ f"{self.base_url}/user/register",
850
+ json={"username": username, "password": "correctpass"},
851
+ timeout=5,
852
+ )
853
+
854
+ # Wrong password
855
+ response = requests.post(
856
+ f"{self.base_url}/user/login",
857
+ json={"username": username, "password": "wrongpass"},
858
+ timeout=5,
859
+ )
860
+ assert response.status_code == 401
861
+ data = cast(
862
+ dict[str, Any], self._extract_transport_response_data(response.json())
863
+ )
864
+ assert data["error"] == "Invalid credentials"
865
+
866
+ # Non-existent user
867
+ response = requests.post(
868
+ f"{self.base_url}/user/login",
869
+ json={"username": "nonexistent", "password": "password"},
870
+ timeout=5,
871
+ )
872
+ assert response.status_code == 401
873
+
874
+ def test_status_code_refresh_token_200_success(self) -> None:
875
+ """Test POST /user/refresh-token returns 200 on successful refresh."""
876
+ # Create user and get token
877
+ create_response = requests.post(
878
+ f"{self.base_url}/user/register",
879
+ json={"username": "status200refresh", "password": "password123"},
880
+ timeout=5,
881
+ )
882
+ create_data = cast(
883
+ dict[str, Any],
884
+ self._extract_transport_response_data(create_response.json()),
885
+ )
886
+ token = create_data["token"]
887
+
888
+ # Refresh token
889
+ response = requests.post(
890
+ f"{self.base_url}/user/refresh-token",
891
+ json={"token": token},
892
+ timeout=5,
893
+ )
894
+ assert response.status_code == 200
895
+ data = cast(
896
+ dict[str, Any], self._extract_transport_response_data(response.json())
897
+ )
898
+ assert "token" in data
899
+ assert data["message"] == "Token refreshed successfully"
900
+
901
+ def test_status_code_refresh_token_400_missing_token(self) -> None:
902
+ """Test POST /user/refresh-token returns 400/422 when token is missing."""
903
+ # Empty token - custom validation returns 400
904
+ response = requests.post(
905
+ f"{self.base_url}/user/refresh-token",
906
+ json={"token": ""},
907
+ timeout=5,
908
+ )
909
+ assert response.status_code == 400
910
+ data = cast(
911
+ dict[str, Any], self._extract_transport_response_data(response.json())
912
+ )
913
+ assert data["error"] == "Token is required"
914
+
915
+ # Null token - FastAPI validation may return 422
916
+ response = requests.post(
917
+ f"{self.base_url}/user/refresh-token",
918
+ json={"token": None},
919
+ timeout=5,
920
+ )
921
+ assert response.status_code in [400, 422]
922
+
923
+ def test_status_code_refresh_token_401_invalid_token(self) -> None:
924
+ """Test POST /user/refresh-token returns 401 for invalid token."""
925
+ # Invalid token format
926
+ response = requests.post(
927
+ f"{self.base_url}/user/refresh-token",
928
+ json={"token": "invalid_token_string"},
929
+ timeout=5,
930
+ )
931
+ assert response.status_code == 401
932
+ data = cast(
933
+ dict[str, Any], self._extract_transport_response_data(response.json())
934
+ )
935
+ assert data["error"] == "Invalid or expired token"
936
+
937
+ # Malformed JWT
938
+ response = requests.post(
939
+ f"{self.base_url}/user/refresh-token",
940
+ json={"token": "not.a.jwt"},
941
+ timeout=5,
942
+ )
943
+ assert response.status_code == 401
944
+
945
+ def test_status_code_walker_200_success(self) -> None:
946
+ """Test POST /walker/{name} returns 200 on successful execution."""
947
+ # Create user
948
+ create_response = requests.post(
949
+ f"{self.base_url}/user/register",
950
+ json={"username": "status200walker", "password": "password123"},
951
+ timeout=5,
952
+ )
953
+ create_data = cast(
954
+ dict[str, Any],
955
+ self._extract_transport_response_data(create_response.json()),
956
+ )
957
+ token = create_data["token"]
958
+
959
+ # Execute walker
960
+ response = requests.post(
961
+ f"{self.base_url}/walker/CreateTask",
962
+ json={"title": "Test Task", "priority": 2},
963
+ headers={"Authorization": f"Bearer {token}"},
964
+ timeout=5,
965
+ )
966
+ assert response.status_code == 200
967
+
968
+ def test_status_code_function_200_success(self) -> None:
969
+ """Test POST /function/{name} returns 200 on successful execution."""
970
+ # Create user
971
+ create_response = requests.post(
972
+ f"{self.base_url}/user/register",
973
+ json={"username": "status200func", "password": "password123"},
974
+ timeout=5,
975
+ )
976
+ create_data = cast(
977
+ dict[str, Any],
978
+ self._extract_transport_response_data(create_response.json()),
979
+ )
980
+ token = create_data["token"]
981
+
982
+ # Execute function
983
+ response = requests.post(
984
+ f"{self.base_url}/function/add_numbers",
985
+ json={"a": 10, "b": 20},
986
+ headers={"Authorization": f"Bearer {token}"},
987
+ timeout=5,
988
+ )
989
+ assert response.status_code == 200
990
+ data = cast(
991
+ dict[str, Any], self._extract_transport_response_data(response.json())
992
+ )
993
+ assert "result" in data
994
+
995
+ def test_status_code_page_404_not_found(self) -> None:
996
+ """Test GET /cl/{name} returns 404 for non-existent page."""
997
+ response = requests.get(
998
+ f"{self.base_url}/cl/nonexistent_page_xyz",
999
+ timeout=5,
1000
+ )
1001
+ assert response.status_code == 404
1002
+ assert "404" in response.text
1003
+
1004
+ def test_status_code_static_client_js_200_or_503(self) -> None:
1005
+ """Test GET /static/client.js returns 200 or 503."""
1006
+ response = requests.get(
1007
+ f"{self.base_url}/static/client.js",
1008
+ timeout=60,
1009
+ )
1010
+ # Should be either 200 (success) or 503 (bundle generation failed)
1011
+ assert response.status_code in [200, 503, 500]
1012
+ if response.status_code == 200:
1013
+ assert "application/javascript" in response.headers.get("content-type", "")
1014
+
1015
+ def test_status_code_static_file_404_not_found(self) -> None:
1016
+ """Test GET /static/{path} returns 404 for non-existent file."""
1017
+ response = requests.get(
1018
+ f"{self.base_url}/static/nonexistent_file.css",
1019
+ timeout=5,
1020
+ )
1021
+ assert response.status_code == 404
1022
+ assert "not found" in response.text.lower()
1023
+
1024
+ def test_status_code_root_asset_404_not_found(self) -> None:
1025
+ """Test GET /{file_path} returns 404 for non-existent asset."""
1026
+ response = requests.get(
1027
+ f"{self.base_url}/nonexistent_image.png",
1028
+ timeout=5,
1029
+ )
1030
+ assert response.status_code == 404
1031
+
1032
+ def test_status_code_root_asset_404_disallowed_extension(self) -> None:
1033
+ """Test GET /{file_path} returns 404 for disallowed file extensions."""
1034
+ # Try .exe file
1035
+ response = requests.get(
1036
+ f"{self.base_url}/malware.exe",
1037
+ timeout=5,
1038
+ )
1039
+ assert response.status_code == 404
1040
+
1041
+ # Try .php file
1042
+ response = requests.get(
1043
+ f"{self.base_url}/script.php",
1044
+ timeout=5,
1045
+ )
1046
+ assert response.status_code == 404
1047
+
1048
+ def test_status_code_root_asset_404_reserved_paths(self) -> None:
1049
+ """Test GET /{file_path} returns 404 for reserved path prefixes."""
1050
+ # These paths should be excluded even with valid extensions
1051
+ reserved_paths = [
1052
+ "page/something.png",
1053
+ "walker/something.png",
1054
+ "function/something.png",
1055
+ "user/something.png",
1056
+ "static/something.png",
1057
+ ]
1058
+
1059
+ for path in reserved_paths:
1060
+ response = requests.get(
1061
+ f"{self.base_url}/{path}",
1062
+ timeout=5,
1063
+ )
1064
+ assert response.status_code == 404
1065
+
1066
+ def test_status_code_integration_auth_flow(self) -> None:
1067
+ """Integration test for complete authentication flow with status codes."""
1068
+ username = "integration_status"
1069
+
1070
+ # Register - 201
1071
+ register_response = requests.post(
1072
+ f"{self.base_url}/user/register",
1073
+ json={"username": username, "password": "secure123"},
1074
+ timeout=5,
1075
+ )
1076
+ assert register_response.status_code == 201
1077
+ data = cast(
1078
+ dict[str, Any],
1079
+ self._extract_transport_response_data(register_response.json()),
1080
+ )
1081
+ token1 = data["token"]
1082
+
1083
+ # Login - 200
1084
+ login_response = requests.post(
1085
+ f"{self.base_url}/user/login",
1086
+ json={"username": username, "password": "secure123"},
1087
+ timeout=5,
1088
+ )
1089
+ assert login_response.status_code == 200
1090
+ data = cast(
1091
+ dict[str, Any], self._extract_transport_response_data(login_response.json())
1092
+ )
1093
+ token2 = data["token"]
1094
+
1095
+ # Refresh token - 200
1096
+ refresh_response = requests.post(
1097
+ f"{self.base_url}/user/refresh-token",
1098
+ json={"token": token1},
1099
+ timeout=5,
1100
+ )
1101
+ assert refresh_response.status_code == 200
1102
+ data = cast(
1103
+ dict[str, Any],
1104
+ self._extract_transport_response_data(refresh_response.json()),
1105
+ )
1106
+ token3 = data["token"]
1107
+
1108
+ # Failed login - 401
1109
+ fail_response = requests.post(
1110
+ f"{self.base_url}/user/login",
1111
+ json={"username": username, "password": "wrongpass"},
1112
+ timeout=5,
1113
+ )
1114
+ assert fail_response.status_code == 401
1115
+
1116
+ # Verify all tokens are different
1117
+ assert token1 != token2
1118
+ assert token2 != token3
1119
+ assert token1 != token3
1120
+
1121
+ def test_private_walker_401_unauthorized(self) -> None:
1122
+ """Test that private walker returns 401 without authentication."""
1123
+ response = requests.post(
1124
+ f"{self.base_url}/walker/PrivateCreateTask",
1125
+ json={"title": "Private Task", "priority": 1},
1126
+ timeout=5,
1127
+ )
1128
+ assert response.status_code == 401
1129
+
1130
+ @pytest.mark.xfail(reason="possible issue with user.json", strict=False)
1131
+ def test_private_walker_200_with_auth(self) -> None:
1132
+ """Test that private walker returns 200 with valid authentication."""
1133
+ # Create user and get token
1134
+ create_result = self._request(
1135
+ "POST",
1136
+ "/user/register",
1137
+ {"username": "privateuser", "password": "password123"},
1138
+ )
1139
+ token = create_result["token"]
1140
+
1141
+ # Call private walker with token
1142
+ response = requests.post(
1143
+ f"{self.base_url}/walker/PrivateCreateTask",
1144
+ json={"title": "Private Task", "priority": 2},
1145
+ headers={"Authorization": f"Bearer {token}"},
1146
+ timeout=5,
1147
+ )
1148
+ assert response.status_code == 200
1149
+ response_data = cast(
1150
+ dict[str, Any], self._extract_transport_response_data(response.json())
1151
+ )
1152
+ data = response_data["reports"][0]
1153
+ assert "message" in data
1154
+ assert data["message"] == "Private task created"
1155
+ assert "task" in data
1156
+
1157
+ def test_public_walker_200_no_auth(self) -> None:
1158
+ """Test that public walker works without authentication."""
1159
+ response = requests.post(
1160
+ f"{self.base_url}/walker/PublicInfo",
1161
+ json={},
1162
+ timeout=5,
1163
+ )
1164
+ assert response.status_code == 200
1165
+ response_data = cast(
1166
+ dict[str, Any], self._extract_transport_response_data(response.json())
1167
+ )
1168
+ data = response_data["reports"][0]
1169
+ assert "message" in data
1170
+ assert data["message"] == "This is a public endpoint"
1171
+ assert "auth_required" in data
1172
+ assert data["auth_required"] is False
1173
+
1174
+ def test_public_walker_200_with_auth(self) -> None:
1175
+ """Test that public walker also works with authentication."""
1176
+ # Create user and get token
1177
+ create_result = self._request(
1178
+ "POST",
1179
+ "/user/register",
1180
+ {"username": "publicuser", "password": "password123"},
1181
+ )
1182
+ token = create_result["token"]
1183
+
1184
+ # Call public walker with token (should still work)
1185
+ response = requests.post(
1186
+ f"{self.base_url}/walker/PublicInfo",
1187
+ json={},
1188
+ headers={"Authorization": f"Bearer {token}"},
1189
+ timeout=5,
1190
+ )
1191
+ assert response.status_code == 200
1192
+ response_data = cast(
1193
+ dict[str, Any], self._extract_transport_response_data(response.json())
1194
+ )
1195
+ data = response_data["reports"][0]
1196
+ assert "message" in data
1197
+ assert data["message"] == "This is a public endpoint"
1198
+
1199
+ def test_custom_response_headers_from_config(self) -> None:
1200
+ """Test that custom response headers from jac.toml are applied."""
1201
+ # Make a request and check for custom headers defined in fixtures/jac.toml
1202
+ response = requests.get(f"{self.base_url}/docs", timeout=5)
1203
+
1204
+ # Check for custom headers configured in jac.toml [environments.response.headers]
1205
+ assert "x-custom-test-header" in response.headers
1206
+ assert response.headers["x-custom-test-header"] == "test-value"
1207
+
1208
+ # Check for COOP/COEP headers (needed for SharedArrayBuffer support)
1209
+ assert "cross-origin-opener-policy" in response.headers
1210
+ assert response.headers["cross-origin-opener-policy"] == "same-origin"
1211
+ assert "cross-origin-embedder-policy" in response.headers
1212
+ assert response.headers["cross-origin-embedder-policy"] == "require-corp"
1213
+
1214
+ def test_update_username_success(self) -> None:
1215
+ """Test successfully updating username and logging in with new username."""
1216
+ # Create user
1217
+ username = f"olduser_{uuid.uuid4().hex[:8]}"
1218
+ create_result = self._request(
1219
+ "POST",
1220
+ "/user/register",
1221
+ {"username": username, "password": "password123"},
1222
+ )
1223
+ original_token = create_result["token"]
1224
+ original_root_id = create_result["root_id"]
1225
+
1226
+ # Update username
1227
+ new_username = f"newuser_{uuid.uuid4().hex[:8]}"
1228
+ update_result = self._request(
1229
+ "PUT",
1230
+ "/user/username",
1231
+ {"current_username": username, "new_username": new_username},
1232
+ token=original_token,
1233
+ )
1234
+
1235
+ assert "username" in update_result
1236
+ assert update_result["username"] == new_username
1237
+ assert "token" in update_result # New token with updated username
1238
+ assert "root_id" in update_result
1239
+ assert (
1240
+ update_result["root_id"] == original_root_id
1241
+ ) # Root ID should remain same
1242
+
1243
+ # Login with new username should work
1244
+ login_result = self._request(
1245
+ "POST",
1246
+ "/user/login",
1247
+ {"username": new_username, "password": "password123"},
1248
+ )
1249
+ assert login_result["username"] == new_username
1250
+ assert "token" in login_result
1251
+
1252
+ # Old username should fail to login
1253
+ login_response = requests.post(
1254
+ f"{self.base_url}/user/login",
1255
+ json={"username": username, "password": "password123"},
1256
+ timeout=5,
1257
+ )
1258
+ assert login_response.status_code == 401
1259
+
1260
+ def test_update_username_requires_auth(self) -> None:
1261
+ """Test that username update requires authentication."""
1262
+ # Create user
1263
+ username = f"authtest_{uuid.uuid4().hex[:8]}"
1264
+ self._request(
1265
+ "POST",
1266
+ "/user/register",
1267
+ {"username": username, "password": "password123"},
1268
+ )
1269
+
1270
+ # Try to update without token
1271
+ response = requests.put(
1272
+ f"{self.base_url}/user/username",
1273
+ json={"current_username": username, "new_username": "newname"},
1274
+ timeout=5,
1275
+ )
1276
+ assert response.status_code == 401
1277
+
1278
+ def test_update_username_cannot_update_other_users(self) -> None:
1279
+ """Test that users cannot update other users' usernames."""
1280
+ # Create user1
1281
+ user1_name = f"user1_{uuid.uuid4().hex[:8]}"
1282
+ user1_result = self._request(
1283
+ "POST",
1284
+ "/user/register",
1285
+ {"username": user1_name, "password": "pass1"},
1286
+ )
1287
+ user1_token = user1_result["token"]
1288
+
1289
+ # Create user2
1290
+ user2_name = f"user2_{uuid.uuid4().hex[:8]}"
1291
+ self._request(
1292
+ "POST",
1293
+ "/user/register",
1294
+ {"username": user2_name, "password": "pass2"},
1295
+ )
1296
+
1297
+ # User1 tries to update user2's username
1298
+ response = requests.put(
1299
+ f"{self.base_url}/user/username",
1300
+ json={"current_username": user2_name, "new_username": "hacked"},
1301
+ headers={"Authorization": f"Bearer {user1_token}"},
1302
+ timeout=5,
1303
+ )
1304
+ assert response.status_code == 403
1305
+
1306
+ def test_update_username_duplicate_fails(self) -> None:
1307
+ """Test that updating to an existing username fails."""
1308
+ # Create user1
1309
+ user1_name = f"user1_{uuid.uuid4().hex[:8]}"
1310
+ user1_result = self._request(
1311
+ "POST",
1312
+ "/user/register",
1313
+ {"username": user1_name, "password": "pass1"},
1314
+ )
1315
+ user1_token = user1_result["token"]
1316
+
1317
+ # Create user2
1318
+ user2_name = f"user2_{uuid.uuid4().hex[:8]}"
1319
+ self._request(
1320
+ "POST",
1321
+ "/user/register",
1322
+ {"username": user2_name, "password": "pass2"},
1323
+ )
1324
+
1325
+ # Try to update user1 to user2 (already exists)
1326
+ response = requests.put(
1327
+ f"{self.base_url}/user/username",
1328
+ json={"current_username": user1_name, "new_username": user2_name},
1329
+ headers={"Authorization": f"Bearer {user1_token}"},
1330
+ timeout=5,
1331
+ )
1332
+ assert response.status_code == 400
1333
+
1334
+ def test_update_username_empty_validation(self) -> None:
1335
+ """Test that empty username is rejected."""
1336
+ # Create user
1337
+ username = f"testuser_{uuid.uuid4().hex[:8]}"
1338
+ user_result = self._request(
1339
+ "POST",
1340
+ "/user/register",
1341
+ {"username": username, "password": "password123"},
1342
+ )
1343
+ token = user_result["token"]
1344
+
1345
+ # Try to update to empty username
1346
+ response = requests.put(
1347
+ f"{self.base_url}/user/username",
1348
+ json={"current_username": username, "new_username": ""},
1349
+ headers={"Authorization": f"Bearer {token}"},
1350
+ timeout=5,
1351
+ )
1352
+ assert response.status_code == 400
1353
+
1354
+ # ==================== PASSWORD UPDATE TESTS ====================
1355
+
1356
+ def test_update_password_success(self) -> None:
1357
+ """Test successfully updating password and logging in with new password."""
1358
+ # Create user
1359
+ username = f"passuser_{uuid.uuid4().hex[:8]}"
1360
+ create_result = self._request(
1361
+ "POST",
1362
+ "/user/register",
1363
+ {"username": username, "password": "oldpass123"},
1364
+ )
1365
+ token = create_result["token"]
1366
+
1367
+ # Update password
1368
+ update_result = self._request(
1369
+ "PUT",
1370
+ "/user/password",
1371
+ {
1372
+ "username": username,
1373
+ "current_password": "oldpass123",
1374
+ "new_password": "newpass456",
1375
+ },
1376
+ token=token,
1377
+ )
1378
+
1379
+ assert "username" in update_result
1380
+ assert update_result["username"] == username
1381
+ assert "message" in update_result or "success" in str(update_result).lower()
1382
+
1383
+ # Login with new password should work
1384
+ login_result = self._request(
1385
+ "POST",
1386
+ "/user/login",
1387
+ {"username": username, "password": "newpass456"},
1388
+ )
1389
+ assert login_result["username"] == username
1390
+
1391
+ # Old password should fail
1392
+ login_response = requests.post(
1393
+ f"{self.base_url}/user/login",
1394
+ json={"username": username, "password": "oldpass123"},
1395
+ timeout=5,
1396
+ )
1397
+ assert login_response.status_code == 401
1398
+
1399
+ def test_update_password_requires_auth(self) -> None:
1400
+ """Test that password update requires authentication."""
1401
+ # Create user
1402
+ username = f"noauthuser_{uuid.uuid4().hex[:8]}"
1403
+ self._request(
1404
+ "POST",
1405
+ "/user/register",
1406
+ {"username": username, "password": "password123"},
1407
+ )
1408
+
1409
+ # Try to update without token
1410
+ response = requests.put(
1411
+ f"{self.base_url}/user/password",
1412
+ json={
1413
+ "username": username,
1414
+ "current_password": "password123",
1415
+ "new_password": "newpass",
1416
+ },
1417
+ timeout=5,
1418
+ )
1419
+ assert response.status_code == 401
1420
+
1421
+ def test_update_password_wrong_current_password(self) -> None:
1422
+ """Test that wrong current password is rejected."""
1423
+ # Create user
1424
+ username = f"wrongpass_{uuid.uuid4().hex[:8]}"
1425
+ user_result = self._request(
1426
+ "POST",
1427
+ "/user/register",
1428
+ {"username": username, "password": "correctpass"},
1429
+ )
1430
+ token = user_result["token"]
1431
+
1432
+ # Try to update with wrong current password
1433
+ response = requests.put(
1434
+ f"{self.base_url}/user/password",
1435
+ json={
1436
+ "username": username,
1437
+ "current_password": "wrongpass",
1438
+ "new_password": "newpass",
1439
+ },
1440
+ headers={"Authorization": f"Bearer {token}"},
1441
+ timeout=5,
1442
+ )
1443
+ assert response.status_code == 400
1444
+
1445
+ def test_update_password_cannot_update_other_users(self) -> None:
1446
+ """Test that users cannot update other users' passwords."""
1447
+ # Create user1
1448
+ user1_name = f"passuser1_{uuid.uuid4().hex[:8]}"
1449
+ user1_result = self._request(
1450
+ "POST",
1451
+ "/user/register",
1452
+ {"username": user1_name, "password": "pass1"},
1453
+ )
1454
+ user1_token = user1_result["token"]
1455
+
1456
+ # Create user2
1457
+ user2_name = f"passuser2_{uuid.uuid4().hex[:8]}"
1458
+ self._request(
1459
+ "POST",
1460
+ "/user/register",
1461
+ {"username": user2_name, "password": "pass2"},
1462
+ )
1463
+
1464
+ # User1 tries to update user2's password
1465
+ response = requests.put(
1466
+ f"{self.base_url}/user/password",
1467
+ json={
1468
+ "username": user2_name,
1469
+ "current_password": "pass2",
1470
+ "new_password": "hacked",
1471
+ },
1472
+ headers={"Authorization": f"Bearer {user1_token}"},
1473
+ timeout=5,
1474
+ )
1475
+ assert response.status_code == 403
1476
+
1477
+ def test_update_password_empty_validation(self) -> None:
1478
+ """Test that empty passwords are rejected."""
1479
+ # Create user
1480
+ username = f"emptypass_{uuid.uuid4().hex[:8]}"
1481
+ user_result = self._request(
1482
+ "POST",
1483
+ "/user/register",
1484
+ {"username": username, "password": "oldpass"},
1485
+ )
1486
+ token = user_result["token"]
1487
+
1488
+ # Try to update to empty new password
1489
+ response = requests.put(
1490
+ f"{self.base_url}/user/password",
1491
+ json={
1492
+ "username": username,
1493
+ "current_password": "oldpass",
1494
+ "new_password": "",
1495
+ },
1496
+ headers={"Authorization": f"Bearer {token}"},
1497
+ timeout=5,
1498
+ )
1499
+ assert response.status_code == 400
1500
+
1501
+ # ==================== INTEGRATION TESTS ====================
1502
+
1503
+ def test_username_and_password_update_flow(self) -> None:
1504
+ """Integration test: Update username, then password, verify both work."""
1505
+ # Create user
1506
+ username = f"original_{uuid.uuid4().hex[:8]}"
1507
+ create_result = self._request(
1508
+ "POST",
1509
+ "/user/register",
1510
+ {"username": username, "password": "oldpass"},
1511
+ )
1512
+ token = create_result["token"]
1513
+ root_id = create_result["root_id"]
1514
+
1515
+ # Update username
1516
+ new_username = f"updated_{uuid.uuid4().hex[:8]}"
1517
+ username_update = self._request(
1518
+ "PUT",
1519
+ "/user/username",
1520
+ {"current_username": username, "new_username": new_username},
1521
+ token=token,
1522
+ )
1523
+ new_token = username_update["token"]
1524
+ assert username_update["root_id"] == root_id
1525
+
1526
+ # Update password with new username and new token
1527
+ password_update = self._request(
1528
+ "PUT",
1529
+ "/user/password",
1530
+ {
1531
+ "username": new_username,
1532
+ "current_password": "oldpass",
1533
+ "new_password": "newpass",
1534
+ },
1535
+ token=new_token,
1536
+ )
1537
+ assert password_update["username"] == new_username
1538
+
1539
+ # Login with new username and new password
1540
+ login_result = self._request(
1541
+ "POST",
1542
+ "/user/login",
1543
+ {"username": new_username, "password": "newpass"},
1544
+ )
1545
+ assert login_result["username"] == new_username
1546
+ assert login_result["root_id"] == root_id
1547
+
1548
+ # Old username should fail
1549
+ old_username_response = requests.post(
1550
+ f"{self.base_url}/user/login",
1551
+ json={"username": username, "password": "newpass"},
1552
+ timeout=5,
1553
+ )
1554
+ assert old_username_response.status_code == 401
1555
+
1556
+ # Old password should fail
1557
+ old_password_response = requests.post(
1558
+ f"{self.base_url}/user/login",
1559
+ json={"username": new_username, "password": "oldpass"},
1560
+ timeout=5,
1561
+ )
1562
+ assert old_password_response.status_code == 401
1563
+
1564
+
1565
+ class TestJacScaleServeDevMode:
1566
+ """Test jac-scale serve with --dev mode (dynamic routing).
1567
+
1568
+ This tests that the dynamic routing endpoints correctly parse request body
1569
+ parameters, which is essential for HMR support.
1570
+ """
1571
+
1572
+ fixtures_dir: Path
1573
+ test_file: Path
1574
+ port: int
1575
+ base_url: str
1576
+ server_process: subprocess.Popen[str] | None = None
1577
+
1578
+ @classmethod
1579
+ def setup_class(cls) -> None:
1580
+ """Set up test class - runs once for all tests."""
1581
+ cls.fixtures_dir = Path(__file__).parent / "fixtures"
1582
+ cls.test_file = cls.fixtures_dir / "test_api.jac"
1583
+
1584
+ if not cls.test_file.exists():
1585
+ raise FileNotFoundError(f"Test fixture not found: {cls.test_file}")
1586
+
1587
+ cls.port = get_free_port()
1588
+ cls.base_url = f"http://localhost:{cls.port}"
1589
+
1590
+ cls._cleanup_db_files()
1591
+ cls.server_process = None
1592
+ cls._start_server_dev_mode()
1593
+
1594
+ @classmethod
1595
+ def teardown_class(cls) -> None:
1596
+ """Tear down test class."""
1597
+ if cls.server_process:
1598
+ cls.server_process.terminate()
1599
+ try:
1600
+ cls.server_process.wait(timeout=5)
1601
+ except subprocess.TimeoutExpired:
1602
+ cls.server_process.kill()
1603
+ cls.server_process.wait()
1604
+
1605
+ time.sleep(0.5)
1606
+ gc.collect()
1607
+ cls._cleanup_db_files()
1608
+
1609
+ @classmethod
1610
+ def _start_server_dev_mode(cls) -> None:
1611
+ """Start the jac-scale server in dev mode (dynamic routing).
1612
+
1613
+ In dev mode, the REST API runs on port+1 while Vite runs on port.
1614
+ We connect directly to the REST API port to avoid Vite dependency issues.
1615
+ """
1616
+ import sys
1617
+
1618
+ jac_executable = Path(sys.executable).parent / "jac"
1619
+ # Use --api-only to skip Vite dev server (if supported), otherwise use base port
1620
+ # The REST API in dev mode runs on base_port + 1
1621
+ vite_port = cls.port
1622
+ api_port = cls.port + 1
1623
+ cls.base_url = f"http://localhost:{api_port}"
1624
+
1625
+ cmd = [
1626
+ str(jac_executable),
1627
+ "start",
1628
+ str(cls.test_file),
1629
+ "--port",
1630
+ str(vite_port),
1631
+ "--dev", # Enable dev mode for dynamic routing
1632
+ ]
1633
+
1634
+ cls.server_process = subprocess.Popen(
1635
+ cmd,
1636
+ stdout=subprocess.PIPE,
1637
+ stderr=subprocess.PIPE,
1638
+ text=True,
1639
+ )
1640
+
1641
+ max_attempts = 50
1642
+ server_ready = False
1643
+
1644
+ for _ in range(max_attempts):
1645
+ if cls.server_process.poll() is not None:
1646
+ stdout, stderr = cls.server_process.communicate()
1647
+ raise RuntimeError(
1648
+ f"Server process terminated unexpectedly.\n"
1649
+ f"STDOUT: {stdout}\nSTDERR: {stderr}"
1650
+ )
1651
+
1652
+ try:
1653
+ # Connect to the REST API port (port+1), not Vite port
1654
+ response = requests.get(f"{cls.base_url}/docs", timeout=2)
1655
+ if response.status_code in (200, 404):
1656
+ print(
1657
+ f"Dev mode server started successfully on API port {api_port}"
1658
+ )
1659
+ server_ready = True
1660
+ break
1661
+ except (requests.ConnectionError, requests.Timeout):
1662
+ time.sleep(2)
1663
+
1664
+ if not server_ready:
1665
+ cls.server_process.terminate()
1666
+ try:
1667
+ stdout, stderr = cls.server_process.communicate(timeout=2)
1668
+ except subprocess.TimeoutExpired:
1669
+ cls.server_process.kill()
1670
+ stdout, stderr = cls.server_process.communicate()
1671
+
1672
+ raise RuntimeError(
1673
+ f"Server failed to start in dev mode after {max_attempts} attempts.\n"
1674
+ f"STDOUT: {stdout}\nSTDERR: {stderr}"
1675
+ )
1676
+
1677
+ @classmethod
1678
+ def _cleanup_db_files(cls) -> None:
1679
+ """Delete SQLite database files."""
1680
+ import shutil
1681
+
1682
+ for pattern in [
1683
+ "*.db",
1684
+ "*.db-wal",
1685
+ "*.db-shm",
1686
+ "anchor_store.db.dat",
1687
+ "anchor_store.db.bak",
1688
+ "anchor_store.db.dir",
1689
+ ]:
1690
+ for db_file in glob.glob(pattern):
1691
+ with contextlib.suppress(Exception):
1692
+ Path(db_file).unlink()
1693
+
1694
+ for pattern in ["*.db", "*.db-wal", "*.db-shm"]:
1695
+ for db_file in glob.glob(str(cls.fixtures_dir / pattern)):
1696
+ with contextlib.suppress(Exception):
1697
+ Path(db_file).unlink()
1698
+
1699
+ client_build_dir = cls.fixtures_dir / ".jac"
1700
+ if client_build_dir.exists():
1701
+ with contextlib.suppress(Exception):
1702
+ shutil.rmtree(client_build_dir)
1703
+
1704
+ @staticmethod
1705
+ def _extract_data(json_response: dict[str, Any]) -> dict[str, Any]:
1706
+ """Extract data from TransportResponse envelope."""
1707
+ if isinstance(json_response, dict) and "ok" in json_response:
1708
+ if json_response.get("ok") and json_response.get("data") is not None:
1709
+ return json_response["data"]
1710
+ elif not json_response.get("ok") and json_response.get("error"):
1711
+ error_info = json_response["error"]
1712
+ return {"error": error_info.get("message", "Unknown error")}
1713
+ return json_response
1714
+
1715
+ def test_dev_mode_walker_body_parsing(self) -> None:
1716
+ """Test that walkers in dev mode correctly parse request body parameters.
1717
+
1718
+ This is a regression test for the fix where dynamic routing endpoints
1719
+ weren't parsing JSON body content into walker fields.
1720
+ """
1721
+ # Register user
1722
+ register_response = requests.post(
1723
+ f"{self.base_url}/user/register",
1724
+ json={"username": f"devtest_{uuid.uuid4().hex[:8]}", "password": "pass"},
1725
+ timeout=10,
1726
+ )
1727
+ assert register_response.status_code == 201
1728
+ token = self._extract_data(register_response.json())["token"]
1729
+
1730
+ # Call walker with body parameters - this is what was broken
1731
+ response = requests.post(
1732
+ f"{self.base_url}/walker/CreateTask",
1733
+ json={"title": "Watch Mode Task", "priority": 5},
1734
+ headers={"Authorization": f"Bearer {token}"},
1735
+ timeout=10,
1736
+ )
1737
+
1738
+ assert response.status_code == 200, (
1739
+ f"Expected 200, got {response.status_code}: {response.text}"
1740
+ )
1741
+ data = self._extract_data(response.json())
1742
+
1743
+ # Verify the walker received and processed the body parameters
1744
+ assert "result" in data or "reports" in data, f"Unexpected response: {data}"
1745
+
1746
+ def test_dev_mode_function_body_parsing(self) -> None:
1747
+ """Test that functions in dev mode correctly parse request body parameters."""
1748
+ # Register user
1749
+ register_response = requests.post(
1750
+ f"{self.base_url}/user/register",
1751
+ json={"username": f"devfunc_{uuid.uuid4().hex[:8]}", "password": "pass"},
1752
+ timeout=10,
1753
+ )
1754
+ assert register_response.status_code == 201
1755
+ token = self._extract_data(register_response.json())["token"]
1756
+
1757
+ # Call function with body parameters
1758
+ response = requests.post(
1759
+ f"{self.base_url}/function/add_numbers",
1760
+ json={"a": 42, "b": 58},
1761
+ headers={"Authorization": f"Bearer {token}"},
1762
+ timeout=10,
1763
+ )
1764
+
1765
+ assert response.status_code == 200, (
1766
+ f"Expected 200, got {response.status_code}: {response.text}"
1767
+ )
1768
+ data = self._extract_data(response.json())
1769
+
1770
+ # Verify the function received and processed the body parameters
1771
+ assert "result" in data, f"Expected 'result' in response: {data}"
1772
+ assert data["result"] == 100, f"Expected 100, got {data['result']}"
1773
+
1774
+ def test_dev_mode_public_walker_no_auth(self) -> None:
1775
+ """Test that public walkers work without auth in dev mode."""
1776
+ response = requests.post(
1777
+ f"{self.base_url}/walker/PublicInfo",
1778
+ json={},
1779
+ timeout=10,
1780
+ )
1781
+
1782
+ assert response.status_code == 200
1783
+ data = self._extract_data(response.json())
1784
+ assert "reports" in data
1785
+ assert data["reports"][0]["message"] == "This is a public endpoint"
1786
+
1787
+ def test_dev_mode_private_walker_requires_auth(self) -> None:
1788
+ """Test that private walkers require auth in dev mode."""
1789
+ response = requests.post(
1790
+ f"{self.base_url}/walker/PrivateCreateTask",
1791
+ json={"title": "Private Task", "priority": 1},
1792
+ timeout=10,
1793
+ )
1794
+
1795
+ assert response.status_code == 401
1796
+
1797
+ # Async Walker Test
1798
+ def test_async_walker_basic_execution(self) -> None:
1799
+ """Test that async walkers execute correctly with await."""
1800
+ # Create user
1801
+ username = f"asyncuser_{uuid.uuid4().hex[:8]}"
1802
+ register_response = requests.post(
1803
+ f"{self.base_url}/user/register",
1804
+ json={"username": username, "password": "password123"},
1805
+ timeout=10,
1806
+ )
1807
+ assert register_response.status_code == 201
1808
+ token = self._extract_data(register_response.json())["token"]
1809
+
1810
+ # Spawn async walker
1811
+ response = requests.post(
1812
+ f"{self.base_url}/walker/AsyncCreateTask",
1813
+ json={"title": "Async Test Task", "delay_ms": 50},
1814
+ headers={"Authorization": f"Bearer {token}"},
1815
+ timeout=10,
1816
+ )
1817
+
1818
+ assert response.status_code == 200, (
1819
+ f"Expected 200, got {response.status_code}: {response.text}"
1820
+ )
1821
+ data = self._extract_data(response.json())
1822
+
1823
+ # Verify reports show the async execution flow
1824
+ assert "reports" in data, f"Expected 'reports' in response: {data}"
1825
+ reports = data["reports"]
1826
+
1827
+ # Should have 3 reports: started, after_async_wait, completed
1828
+ assert len(reports) >= 3, f"Expected at least 3 reports, got {len(reports)}"
1829
+
1830
+ # Check the execution order
1831
+ assert reports[0]["status"] == "started"
1832
+ assert reports[0]["title"] == "Async Test Task"
1833
+ assert reports[1]["status"] == "after_async_wait"
1834
+ assert reports[2]["status"] == "completed"
1835
+ assert "task" in reports[2]