jac-scale 0.1.1__py3-none-any.whl → 0.1.4__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 (36) hide show
  1. jac_scale/abstractions/config/app_config.jac +5 -2
  2. jac_scale/config_loader.jac +2 -1
  3. jac_scale/context.jac +2 -1
  4. jac_scale/factories/storage_factory.jac +75 -0
  5. jac_scale/google_sso_provider.jac +85 -0
  6. jac_scale/impl/config_loader.impl.jac +28 -3
  7. jac_scale/impl/context.impl.jac +1 -0
  8. jac_scale/impl/serve.impl.jac +749 -266
  9. jac_scale/impl/user_manager.impl.jac +349 -0
  10. jac_scale/impl/webhook.impl.jac +212 -0
  11. jac_scale/jserver/impl/jfast_api.impl.jac +4 -0
  12. jac_scale/memory_hierarchy.jac +3 -1
  13. jac_scale/plugin.jac +46 -3
  14. jac_scale/plugin_config.jac +28 -1
  15. jac_scale/serve.jac +33 -16
  16. jac_scale/sso_provider.jac +72 -0
  17. jac_scale/targets/kubernetes/kubernetes_config.jac +9 -15
  18. jac_scale/targets/kubernetes/kubernetes_target.jac +174 -15
  19. jac_scale/tests/fixtures/scale-feats/components/Button.cl.jac +32 -0
  20. jac_scale/tests/fixtures/scale-feats/main.jac +147 -0
  21. jac_scale/tests/fixtures/test_api.jac +89 -0
  22. jac_scale/tests/fixtures/test_restspec.jac +88 -0
  23. jac_scale/tests/test_deploy_k8s.py +2 -1
  24. jac_scale/tests/test_examples.py +180 -5
  25. jac_scale/tests/test_hooks.py +39 -0
  26. jac_scale/tests/test_restspec.py +289 -0
  27. jac_scale/tests/test_serve.py +411 -4
  28. jac_scale/tests/test_sso.py +273 -284
  29. jac_scale/tests/test_storage.py +274 -0
  30. jac_scale/user_manager.jac +49 -0
  31. jac_scale/webhook.jac +93 -0
  32. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/METADATA +11 -4
  33. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/RECORD +36 -23
  34. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/WHEEL +1 -1
  35. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/entry_points.txt +0 -0
  36. {jac_scale-0.1.1.dist-info → jac_scale-0.1.4.dist-info}/top_level.txt +0 -0
@@ -3,6 +3,9 @@
3
3
  import contextlib
4
4
  import gc
5
5
  import glob
6
+ import hashlib
7
+ import hmac
8
+ import json
6
9
  import socket
7
10
  import subprocess
8
11
  import time
@@ -260,7 +263,7 @@ class TestJacScaleServe:
260
263
  def _create_expired_token(self, username: str, days_ago: int = 1) -> str:
261
264
  """Create an expired JWT token for testing."""
262
265
  # Use the same secret as the server (default)
263
- secret = "supersecretkey"
266
+ secret = "supersecretkey_for_testing_only!"
264
267
  algorithm = "HS256"
265
268
 
266
269
  past_time = datetime.now(UTC) - timedelta(days=days_ago)
@@ -273,7 +276,7 @@ class TestJacScaleServe:
273
276
 
274
277
  def _create_very_old_token(self, username: str, days_ago: int = 15) -> str:
275
278
  """Create a token that's too old to refresh."""
276
- secret = "supersecretkey"
279
+ secret = "supersecretkey_for_testing_only!"
277
280
  algorithm = "HS256"
278
281
 
279
282
  past_time = datetime.now(UTC) - timedelta(days=days_ago)
@@ -505,7 +508,7 @@ class TestJacScaleServe:
505
508
  new_token = refresh_result["token"]
506
509
 
507
510
  # Decode both tokens and verify username is preserved
508
- secret = "supersecretkey"
511
+ secret = "supersecretkey_for_testing_only!"
509
512
  algorithm = "HS256"
510
513
 
511
514
  original_payload = pyjwt.decode(original_token, secret, algorithms=[algorithm])
@@ -535,7 +538,7 @@ class TestJacScaleServe:
535
538
  new_token = refresh_result["token"]
536
539
 
537
540
  # Decode tokens and compare expiration times
538
- secret = "supersecretkey"
541
+ secret = "supersecretkey_for_testing_only!"
539
542
  algorithm = "HS256"
540
543
 
541
544
  original_payload = pyjwt.decode(original_token, secret, algorithms=[algorithm])
@@ -1561,6 +1564,356 @@ class TestJacScaleServe:
1561
1564
  )
1562
1565
  assert old_password_response.status_code == 401
1563
1566
 
1567
+ # ========================================================================
1568
+ # Webhook Walker Tests
1569
+ # ========================================================================
1570
+
1571
+ def _generate_webhook_signature(self, payload: bytes, secret: str) -> str:
1572
+ """Generate HMAC-SHA256 signature for webhook payload."""
1573
+ return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
1574
+
1575
+ def test_webhook_endpoint_exists_for_webhook_walkers(self) -> None:
1576
+ """Test 1: Verify webhook endpoints are registered for walkers with @restspec(webhook=True)."""
1577
+ response = requests.get(f"{self.base_url}/openapi.json", timeout=5)
1578
+ assert response.status_code == 200
1579
+ schema = response.json()
1580
+ paths = schema.get("paths", {})
1581
+
1582
+ # PaymentReceived has @restspec(webhook=True) -> should have /webhook/ endpoint
1583
+ assert "/webhook/PaymentReceived" in paths, (
1584
+ f"Expected /webhook/PaymentReceived in paths: {list(paths.keys())}"
1585
+ )
1586
+ # MinimalWebhook has @restspec(webhook=True) -> should have /webhook/ endpoint
1587
+ assert "/webhook/MinimalWebhook" in paths, (
1588
+ f"Expected /webhook/MinimalWebhook in paths: {list(paths.keys())}"
1589
+ )
1590
+
1591
+ def test_normal_walker_not_in_webhook_endpoint(self) -> None:
1592
+ """Test 2: Verify normal walkers (without @restspec(webhook=True)) are NOT in /webhook/."""
1593
+ response = requests.get(f"{self.base_url}/openapi.json", timeout=5)
1594
+ assert response.status_code == 200
1595
+ schema = response.json()
1596
+ paths = schema.get("paths", {})
1597
+
1598
+ # NormalPayment does NOT have @restspec(webhook=True) -> should NOT have /webhook/ endpoint
1599
+ assert "/webhook/NormalPayment" not in paths, (
1600
+ "NormalPayment should NOT have webhook endpoint but found in paths"
1601
+ )
1602
+ # NormalPayment should be accessible via /walker/ endpoint
1603
+ assert "/walker/NormalPayment" in paths or "/walker/{walker_name}" in paths, (
1604
+ "NormalPayment should be accessible via /walker/ endpoint"
1605
+ )
1606
+
1607
+ def test_normal_walker_accessible_via_walker_endpoint(self) -> None:
1608
+ """Test 2b: Verify NormalPayment (no webhook restspec) works via /walker/ endpoint."""
1609
+ # Create user and get token
1610
+ username = f"normal_walker_user_{uuid.uuid4().hex[:8]}"
1611
+ register_response = requests.post(
1612
+ f"{self.base_url}/user/register",
1613
+ json={"username": username, "password": "password123"},
1614
+ timeout=10,
1615
+ )
1616
+ assert register_response.status_code == 201
1617
+ register_data = cast(
1618
+ dict[str, Any],
1619
+ self._extract_transport_response_data(register_response.json()),
1620
+ )
1621
+ token = register_data["token"]
1622
+
1623
+ # Call NormalPayment via /walker/ endpoint
1624
+ response = requests.post(
1625
+ f"{self.base_url}/walker/NormalPayment",
1626
+ json={
1627
+ "payment_id": "PAY-NORMAL-001",
1628
+ "order_id": "ORD-NORMAL-001",
1629
+ "amount": 50.00,
1630
+ "currency": "EUR",
1631
+ },
1632
+ headers={"Authorization": f"Bearer {token}"},
1633
+ timeout=10,
1634
+ )
1635
+
1636
+ assert response.status_code == 200, (
1637
+ f"Expected 200, got {response.status_code}: {response.text}"
1638
+ )
1639
+ data = cast(
1640
+ dict[str, Any], self._extract_transport_response_data(response.json())
1641
+ )
1642
+ assert "reports" in data
1643
+ report = data["reports"][0]
1644
+ assert report["status"] == "success"
1645
+ assert report["payment_id"] == "PAY-NORMAL-001"
1646
+ assert report["transport"] == "http"
1647
+
1648
+ def test_webhook_requires_api_key(self) -> None:
1649
+ """Test that webhook endpoints require API key authentication."""
1650
+ payload = json.dumps({})
1651
+
1652
+ # Try to call MinimalWebhook without API key
1653
+ response = requests.post(
1654
+ f"{self.base_url}/webhook/MinimalWebhook",
1655
+ data=payload,
1656
+ headers={"Content-Type": "application/json"},
1657
+ timeout=5,
1658
+ )
1659
+
1660
+ # Should fail - 401 (our custom error) or 422 (FastAPI validation for missing header)
1661
+ assert response.status_code in (401, 422), (
1662
+ f"Expected 401 or 422, got {response.status_code}: {response.text}"
1663
+ )
1664
+
1665
+ def test_webhook_invalid_api_key(self) -> None:
1666
+ """Test that webhook endpoints reject invalid API keys."""
1667
+ payload = json.dumps({})
1668
+
1669
+ # Try with invalid API key
1670
+ response = requests.post(
1671
+ f"{self.base_url}/webhook/MinimalWebhook",
1672
+ data=payload,
1673
+ headers={
1674
+ "Content-Type": "application/json",
1675
+ "X-API-Key": "invalid_key_12345",
1676
+ },
1677
+ timeout=5,
1678
+ )
1679
+
1680
+ # Should fail with 401 Unauthorized
1681
+ assert response.status_code == 401, (
1682
+ f"Expected 401, got {response.status_code}: {response.text}"
1683
+ )
1684
+
1685
+ def test_minimal_webhook_with_valid_api_key(self) -> None:
1686
+ """Test 3: MinimalWebhook (with @restspec(webhook=True)) works with valid API key."""
1687
+ # Create user and get auth token
1688
+ username = f"minimal_webhook_user_{uuid.uuid4().hex[:8]}"
1689
+ register_response = requests.post(
1690
+ f"{self.base_url}/user/register",
1691
+ json={"username": username, "password": "password123"},
1692
+ timeout=10,
1693
+ )
1694
+ assert register_response.status_code == 201
1695
+ register_data = cast(
1696
+ dict[str, Any],
1697
+ self._extract_transport_response_data(register_response.json()),
1698
+ )
1699
+ token = register_data["token"]
1700
+
1701
+ # Create API key
1702
+ api_key_response = requests.post(
1703
+ f"{self.base_url}/api-key/create",
1704
+ json={"name": "minimal_webhook_key", "expiry_days": 30},
1705
+ headers={"Authorization": f"Bearer {token}"},
1706
+ timeout=10,
1707
+ )
1708
+ assert api_key_response.status_code == 201, (
1709
+ f"Failed to create API key: {api_key_response.text}"
1710
+ )
1711
+ api_key_data = cast(
1712
+ dict[str, Any],
1713
+ self._extract_transport_response_data(api_key_response.json()),
1714
+ )
1715
+ api_key = api_key_data["api_key"]
1716
+
1717
+ # Call MinimalWebhook with valid API key (empty payload - no fields required)
1718
+ payload = json.dumps({})
1719
+ payload_bytes = payload.encode("utf-8")
1720
+ signature = self._generate_webhook_signature(payload_bytes, api_key)
1721
+ response = requests.post(
1722
+ f"{self.base_url}/webhook/MinimalWebhook",
1723
+ data=payload,
1724
+ headers={
1725
+ "Content-Type": "application/json",
1726
+ "X-API-Key": api_key,
1727
+ "X-Webhook-Signature": signature,
1728
+ },
1729
+ timeout=10,
1730
+ )
1731
+
1732
+ assert response.status_code == 200, (
1733
+ f"Expected 200, got {response.status_code}: {response.text}"
1734
+ )
1735
+ data = cast(
1736
+ dict[str, Any], self._extract_transport_response_data(response.json())
1737
+ )
1738
+ assert "reports" in data
1739
+ assert data["reports"][0]["status"] == "received"
1740
+ assert data["reports"][0]["transport"] == "webhook"
1741
+
1742
+ def test_webhook_payment_received_with_fields(self) -> None:
1743
+ """Test 1: PaymentReceived webhook walker with multiple fields + @restspec(webhook=True)."""
1744
+ # Create user and get API key
1745
+ username = f"payment_user_{uuid.uuid4().hex[:8]}"
1746
+ register_response = requests.post(
1747
+ f"{self.base_url}/user/register",
1748
+ json={"username": username, "password": "password123"},
1749
+ timeout=10,
1750
+ )
1751
+ assert register_response.status_code == 201
1752
+ register_data = cast(
1753
+ dict[str, Any],
1754
+ self._extract_transport_response_data(register_response.json()),
1755
+ )
1756
+ token = register_data["token"]
1757
+
1758
+ api_key_response = requests.post(
1759
+ f"{self.base_url}/api-key/create",
1760
+ json={"name": "payment_webhook_key", "expiry_days": 30},
1761
+ headers={"Authorization": f"Bearer {token}"},
1762
+ timeout=10,
1763
+ )
1764
+ assert api_key_response.status_code == 201
1765
+ api_key_data = cast(
1766
+ dict[str, Any],
1767
+ self._extract_transport_response_data(api_key_response.json()),
1768
+ )
1769
+ api_key = api_key_data["api_key"]
1770
+
1771
+ # Send payment webhook
1772
+ payload = json.dumps(
1773
+ {
1774
+ "payment_id": "PAY-12345",
1775
+ "order_id": "ORD-67890",
1776
+ "amount": 99.99,
1777
+ "currency": "USD",
1778
+ }
1779
+ )
1780
+ payload_bytes = payload.encode("utf-8")
1781
+ signature = self._generate_webhook_signature(payload_bytes, api_key)
1782
+
1783
+ response = requests.post(
1784
+ f"{self.base_url}/webhook/PaymentReceived",
1785
+ data=payload,
1786
+ headers={
1787
+ "Content-Type": "application/json",
1788
+ "X-API-Key": api_key,
1789
+ "X-Webhook-Signature": signature,
1790
+ },
1791
+ timeout=10,
1792
+ )
1793
+
1794
+ assert response.status_code == 200, (
1795
+ f"Expected 200, got {response.status_code}: {response.text}"
1796
+ )
1797
+ data = cast(
1798
+ dict[str, Any], self._extract_transport_response_data(response.json())
1799
+ )
1800
+ assert "reports" in data
1801
+ report = data["reports"][0]
1802
+ assert report["status"] == "success"
1803
+ assert report["payment_id"] == "PAY-12345"
1804
+ assert report["order_id"] == "ORD-67890"
1805
+ assert report["amount"] == 99.99
1806
+ assert report["currency"] == "USD"
1807
+
1808
+ def test_webhook_not_accessible_via_regular_walker_endpoint(self) -> None:
1809
+ """Test that webhook walkers (with @restspec(webhook=True)) are NOT accessible via /walker/."""
1810
+ # Create user and get token
1811
+ username = f"webhook_path_user_{uuid.uuid4().hex[:8]}"
1812
+ register_response = requests.post(
1813
+ f"{self.base_url}/user/register",
1814
+ json={"username": username, "password": "password123"},
1815
+ timeout=10,
1816
+ )
1817
+ assert register_response.status_code == 201
1818
+ register_data = cast(
1819
+ dict[str, Any],
1820
+ self._extract_transport_response_data(register_response.json()),
1821
+ )
1822
+ token = register_data["token"]
1823
+
1824
+ # Try to access PaymentReceived (webhook walker) via /walker/ endpoint
1825
+ response = requests.post(
1826
+ f"{self.base_url}/walker/PaymentReceived",
1827
+ json={
1828
+ "payment_id": "PAY-TEST",
1829
+ "order_id": "ORD-TEST",
1830
+ "amount": 10.00,
1831
+ },
1832
+ headers={"Authorization": f"Bearer {token}"},
1833
+ timeout=10,
1834
+ )
1835
+
1836
+ # Webhook walkers should not be accessible via /walker/ endpoint
1837
+ # In static mode: 404 (Not Found) or 405 (Method Not Allowed - no POST registered)
1838
+ # In dynamic mode: 400 (Bad Request - explicitly rejected)
1839
+ assert response.status_code in (400, 404, 405), (
1840
+ f"Expected 400/404/405, got {response.status_code}: {response.text}"
1841
+ )
1842
+
1843
+ def test_webhook_revoked_api_key(self) -> None:
1844
+ """Test that revoked API keys are rejected."""
1845
+ # Create user and get auth token
1846
+ username = f"webhook_revoke_user_{uuid.uuid4().hex[:8]}"
1847
+ register_response = requests.post(
1848
+ f"{self.base_url}/user/register",
1849
+ json={"username": username, "password": "password123"},
1850
+ timeout=10,
1851
+ )
1852
+ assert register_response.status_code == 201
1853
+ register_data = cast(
1854
+ dict[str, Any],
1855
+ self._extract_transport_response_data(register_response.json()),
1856
+ )
1857
+ token = register_data["token"]
1858
+
1859
+ # Create API key
1860
+ api_key_response = requests.post(
1861
+ f"{self.base_url}/api-key/create",
1862
+ json={"name": "key_to_revoke", "expiry_days": 30},
1863
+ headers={"Authorization": f"Bearer {token}"},
1864
+ timeout=10,
1865
+ )
1866
+ assert api_key_response.status_code == 201
1867
+ api_key_data = cast(
1868
+ dict[str, Any],
1869
+ self._extract_transport_response_data(api_key_response.json()),
1870
+ )
1871
+ api_key = api_key_data["api_key"]
1872
+ api_key_id = api_key_data["api_key_id"]
1873
+
1874
+ # Verify API key works with MinimalWebhook
1875
+ payload = json.dumps({})
1876
+ payload_bytes = payload.encode("utf-8")
1877
+ signature = self._generate_webhook_signature(payload_bytes, api_key)
1878
+ response = requests.post(
1879
+ f"{self.base_url}/webhook/MinimalWebhook",
1880
+ data=payload,
1881
+ headers={
1882
+ "Content-Type": "application/json",
1883
+ "X-API-Key": api_key,
1884
+ "X-Webhook-Signature": signature,
1885
+ },
1886
+ timeout=10,
1887
+ )
1888
+ assert response.status_code == 200
1889
+
1890
+ # Revoke the API key
1891
+ revoke_response = requests.delete(
1892
+ f"{self.base_url}/api-key/{api_key_id}",
1893
+ headers={"Authorization": f"Bearer {token}"},
1894
+ timeout=10,
1895
+ )
1896
+ assert revoke_response.status_code == 200, (
1897
+ f"Failed to revoke key: {revoke_response.text}"
1898
+ )
1899
+
1900
+ # Try to use revoked key (same payload and signature)
1901
+ response = requests.post(
1902
+ f"{self.base_url}/webhook/MinimalWebhook",
1903
+ data=payload,
1904
+ headers={
1905
+ "Content-Type": "application/json",
1906
+ "X-API-Key": api_key,
1907
+ "X-Webhook-Signature": signature,
1908
+ },
1909
+ timeout=10,
1910
+ )
1911
+
1912
+ # Should fail with 401
1913
+ assert response.status_code == 401, (
1914
+ f"Expected 401 for revoked key, got {response.status_code}: {response.text}"
1915
+ )
1916
+
1564
1917
 
1565
1918
  class TestJacScaleServeDevMode:
1566
1919
  """Test jac-scale serve with --dev mode (dynamic routing).
@@ -1833,3 +2186,57 @@ class TestJacScaleServeDevMode:
1833
2186
  assert reports[1]["status"] == "after_async_wait"
1834
2187
  assert reports[2]["status"] == "completed"
1835
2188
  assert "task" in reports[2]
2189
+
2190
+ def test_walker_stream_response(self) -> None:
2191
+ """Test that walker streaming responses work correctly."""
2192
+ response = requests.post(
2193
+ f"{self.base_url}/walker/WalkerStream",
2194
+ json={"count": 3},
2195
+ timeout=30,
2196
+ stream=True,
2197
+ )
2198
+
2199
+ assert response.status_code == 200, (
2200
+ f"Failed with status {response.status_code}: {response.text}"
2201
+ )
2202
+ assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
2203
+ assert response.headers.get("cache-control") == "no-cache"
2204
+ assert response.headers.get("connection") == "close"
2205
+
2206
+ # Collect streaming content
2207
+ content = ""
2208
+ for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
2209
+ if chunk:
2210
+ content += chunk
2211
+
2212
+ # The generator yields "Report 0", "Report 1", "Report 2" without delimiters
2213
+ # They get concatenated together in the stream
2214
+ expected = "Report 0Report 1Report 2"
2215
+ assert content == expected, f"Expected '{expected}', got '{content}'"
2216
+
2217
+ def test_function_stream_response(self) -> None:
2218
+ """Test that function streaming responses work correctly."""
2219
+ response = requests.post(
2220
+ f"{self.base_url}/function/FunctionStream",
2221
+ json={"count": 2},
2222
+ timeout=30,
2223
+ stream=True,
2224
+ )
2225
+
2226
+ assert response.status_code == 200, (
2227
+ f"Failed with status {response.status_code}: {response.text}"
2228
+ )
2229
+ assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
2230
+ assert response.headers.get("cache-control") == "no-cache"
2231
+ assert response.headers.get("connection") == "close"
2232
+
2233
+ # Collect streaming content
2234
+ content = ""
2235
+ for chunk in response.iter_content(chunk_size=1024, decode_unicode=True):
2236
+ if chunk:
2237
+ content += chunk
2238
+
2239
+ # The generator yields "Func 0", "Func 1" without delimiters
2240
+ # They get concatenated together in the stream
2241
+ expected = "Func 0Func 1"
2242
+ assert content == expected, f"Expected '{expected}', got '{content}'"