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,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]
|