jac-scale 0.1.3__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.
- jac_scale/config_loader.jac +2 -1
- jac_scale/impl/config_loader.impl.jac +28 -3
- jac_scale/impl/context.impl.jac +1 -3
- jac_scale/impl/serve.impl.jac +667 -32
- jac_scale/impl/webhook.impl.jac +212 -0
- jac_scale/jserver/impl/jfast_api.impl.jac +4 -0
- jac_scale/plugin_config.jac +1 -1
- jac_scale/serve.jac +30 -4
- jac_scale/tests/fixtures/test_api.jac +60 -0
- jac_scale/tests/fixtures/test_restspec.jac +51 -0
- jac_scale/tests/test_restspec.py +97 -0
- jac_scale/tests/test_serve.py +357 -4
- jac_scale/webhook.jac +93 -0
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/METADATA +4 -4
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/RECORD +18 -16
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/WHEEL +0 -0
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/entry_points.txt +0 -0
- {jac_scale-0.1.3.dist-info → jac_scale-0.1.4.dist-info}/top_level.txt +0 -0
jac_scale/tests/test_serve.py
CHANGED
|
@@ -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 = "
|
|
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 = "
|
|
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 = "
|
|
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 = "
|
|
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).
|
jac_scale/webhook.jac
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Webhook support for Jac Scale.
|
|
2
|
+
|
|
3
|
+
This module provides webhook-related functionality including:
|
|
4
|
+
- API key generation and management
|
|
5
|
+
- HMAC-SHA256 signature verification for webhook security
|
|
6
|
+
- Webhook configuration and utilities
|
|
7
|
+
|
|
8
|
+
API keys are generated per-user and can be used to authenticate webhook calls.
|
|
9
|
+
The API key is wrapped in a JWT for secure storage and validation.
|
|
10
|
+
"""
|
|
11
|
+
import hmac;
|
|
12
|
+
import hashlib;
|
|
13
|
+
import secrets;
|
|
14
|
+
import jwt;
|
|
15
|
+
import from datetime { UTC, datetime, timedelta }
|
|
16
|
+
import from typing { Any }
|
|
17
|
+
import from pydantic { BaseModel, Field }
|
|
18
|
+
import from jaclang.runtimelib.transport { TransportResponse, Meta }
|
|
19
|
+
import from jac_scale.config_loader { get_scale_config }
|
|
20
|
+
|
|
21
|
+
# Load webhook configuration
|
|
22
|
+
glob _webhook_config = get_scale_config().get_webhook_config(),
|
|
23
|
+
WEBHOOK_SECRET = _webhook_config['secret'],
|
|
24
|
+
WEBHOOK_SIGNATURE_HEADER = _webhook_config['signature_header'],
|
|
25
|
+
WEBHOOK_VERIFY_SIGNATURE = _webhook_config['verify_signature'],
|
|
26
|
+
WEBHOOK_API_KEY_EXPIRY_DAYS = _webhook_config['api_key_expiry_days'];
|
|
27
|
+
|
|
28
|
+
"""Pydantic model for API key creation request."""
|
|
29
|
+
class CreateApiKeyRequest(BaseModel) {
|
|
30
|
+
has name: str = Field(..., description='A friendly name for the API key'),
|
|
31
|
+
expiry_days: int | None = Field(
|
|
32
|
+
None, description='Number of days until expiry (default from config)'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
"""Pydantic model for API key response."""
|
|
37
|
+
class ApiKeyResponse(BaseModel) {
|
|
38
|
+
has api_key: str = Field(..., description='The generated API key'),
|
|
39
|
+
api_key_id: str = Field(..., description='Unique identifier for the API key'),
|
|
40
|
+
name: str = Field(..., description='Friendly name for the API key'),
|
|
41
|
+
created_at: str = Field(..., description='ISO timestamp of creation'),
|
|
42
|
+
expires_at: str | None = Field(
|
|
43
|
+
None, description='ISO timestamp of expiry, or null if no expiry'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
"""Webhook utilities for signature generation and verification."""
|
|
48
|
+
obj WebhookUtils {
|
|
49
|
+
"""Generate HMAC-SHA256 signature for webhook payload."""
|
|
50
|
+
static def generate_signature(payload: bytes, secret: str) -> str;
|
|
51
|
+
|
|
52
|
+
"""Verify HMAC-SHA256 signature for webhook payload."""
|
|
53
|
+
static def verify_signature(payload: bytes, signature: str, secret: str) -> bool;
|
|
54
|
+
|
|
55
|
+
"""Generate a new API key."""
|
|
56
|
+
static def generate_api_key -> str;
|
|
57
|
+
|
|
58
|
+
"""Create a JWT-wrapped API key token.
|
|
59
|
+
|
|
60
|
+
The API key is embedded in a JWT for secure validation and expiry management."""
|
|
61
|
+
static def create_api_key_token(
|
|
62
|
+
api_key_id: str, username: str, name: str, expiry_days: int | None = None
|
|
63
|
+
) -> str;
|
|
64
|
+
|
|
65
|
+
"""Validate an API key token and extract user information."""
|
|
66
|
+
static def validate_api_key(api_key: str) -> dict[str, str] | None;
|
|
67
|
+
|
|
68
|
+
"""Extract signature from request header value."""
|
|
69
|
+
static def extract_signature(header_value: str) -> str;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
"""Manager for API keys associated with users."""
|
|
73
|
+
obj ApiKeyManager {
|
|
74
|
+
has _api_keys: dict[str, dict[str, Any]] = {}, # api_key_id -> key info
|
|
75
|
+
_user_keys: dict[str, list[str]] = {}; # username -> list of api_key_ids
|
|
76
|
+
|
|
77
|
+
"""Create a new API key for a user."""
|
|
78
|
+
def create_api_key(
|
|
79
|
+
username: str, name: str, expiry_days: int | None = None
|
|
80
|
+
) -> TransportResponse;
|
|
81
|
+
|
|
82
|
+
"""List all API keys for a user."""
|
|
83
|
+
def list_api_keys(username: str) -> TransportResponse;
|
|
84
|
+
|
|
85
|
+
"""Revoke an API key."""
|
|
86
|
+
def revoke_api_key(username: str, api_key_id: str) -> TransportResponse;
|
|
87
|
+
|
|
88
|
+
"""Validate an API key and return the associated username."""
|
|
89
|
+
def validate_api_key(api_key: str) -> str | None;
|
|
90
|
+
|
|
91
|
+
"""Check if an API key ID exists and is not revoked."""
|
|
92
|
+
def is_key_active(api_key_id: str) -> bool;
|
|
93
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jac-scale
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Author-email: Jason Mars <jason@mars.ninja>
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
|
-
Requires-Dist: jaclang>=0.9.
|
|
7
|
+
Requires-Dist: jaclang>=0.9.13
|
|
8
8
|
Requires-Dist: python-dotenv<2.0.0,>=1.2.1
|
|
9
9
|
Requires-Dist: docker<8.0.0,>=7.1.0
|
|
10
10
|
Requires-Dist: kubernetes<35.0.0,>=34.1.0
|
|
@@ -12,7 +12,7 @@ Requires-Dist: pymongo<5.0.0,>=4.15.4
|
|
|
12
12
|
Requires-Dist: redis<8.0.0,>=7.1.0
|
|
13
13
|
Requires-Dist: fastapi<0.122.0,>=0.121.3
|
|
14
14
|
Requires-Dist: uvicorn<0.39.0,>=0.38.0
|
|
15
|
-
Requires-Dist: pyjwt
|
|
15
|
+
Requires-Dist: pyjwt<2.11.0,>=2.10.1
|
|
16
16
|
Requires-Dist: fastapi-sso<1.0.0,>=0.18.0
|
|
17
17
|
Requires-Dist: python-multipart<1.0.0,>=0.0.21
|
|
18
18
|
|
|
@@ -491,7 +491,7 @@ async walker FetchData {
|
|
|
491
491
|
| `MONGODB_URI` | URL of MongoDB database | - |
|
|
492
492
|
| `REDIS_URL` | URL of Redis database | - |
|
|
493
493
|
| `JWT_EXP_DELTA_DAYS` | Number of days until JWT token expires | `7` |
|
|
494
|
-
| `JWT_SECRET` | Secret key used for JWT token signing and verification | `'
|
|
494
|
+
| `JWT_SECRET` | Secret key used for JWT token signing and verification | `'supersecretkey_for_testing_only!'` |
|
|
495
495
|
| `JWT_ALGORITHM` | Algorithm used for JWT token encoding/decoding | `'HS256'` |
|
|
496
496
|
| `SSO_HOST` | SSO host URL | `'http://localhost:8000/sso'` |
|
|
497
497
|
| `SSO_GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
jac_scale/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
jac_scale/config_loader.jac,sha256=
|
|
2
|
+
jac_scale/config_loader.jac,sha256=fG1dN9AS54bzNL5XvPHNS4oMw8kknZnlAZ7ByPT0APU,1298
|
|
3
3
|
jac_scale/context.jac,sha256=SIqQineAlLHJV3yWtYkeTIfcnqS0Oj15n8CWVSIqwJ8,529
|
|
4
4
|
jac_scale/google_sso_provider.jac,sha256=UUTDgQrBHXp0eqHxiw7v_VkQyFMMza0UDM6S4T5e4OM,2473
|
|
5
5
|
jac_scale/memory_hierarchy.jac,sha256=GuydhwujsH71TAkxeJ6Wfutp4v6GjDj23vGMreRj4yg,4533
|
|
6
6
|
jac_scale/plugin.jac,sha256=E1Ou_sQ8RV35emY9-gM295Uu-Eg4w4qSEJYm3TTYsWY,9585
|
|
7
|
-
jac_scale/plugin_config.jac,sha256=
|
|
8
|
-
jac_scale/serve.jac,sha256=
|
|
7
|
+
jac_scale/plugin_config.jac,sha256=6_cTMzjDhr-7fGh2RMM5LCfjXlfKduGyFs0S0PnHKXQ,8718
|
|
8
|
+
jac_scale/serve.jac,sha256=8xMOHX3X-sJFx6yENolL-7VM7Km1Mg5BPkUtMLUl9O4,5546
|
|
9
9
|
jac_scale/sso_provider.jac,sha256=Kky8cxm5gR2Sbg-CFrWTILKSZrV6Ju-DCEgfG4Akxb8,2092
|
|
10
10
|
jac_scale/user_manager.jac,sha256=UBApt_znhVEyxtfAdJ9sp_gslkHel4GGwXCBcNen_as,1932
|
|
11
11
|
jac_scale/utils.jac,sha256=zpxA08_NlDDSd1oVlbknQ_UOwHWGBbhzjmT_u6UYN04,491
|
|
12
|
+
jac_scale/webhook.jac,sha256=lTpevPoe-CEcj0-MAd8c1fHK7jGUL3YnN4_ouMeq4Ic,3660
|
|
12
13
|
jac_scale/abstractions/database_provider.jac,sha256=NZbFmcESulYsmG-27f8GY9nxVs3JflGIEb3aDDOIPu0,1454
|
|
13
14
|
jac_scale/abstractions/deployment_target.jac,sha256=5rJOd25FUXZZCNmDSUkyMljcXTAVJJDmelKw8WdPZm0,2071
|
|
14
15
|
jac_scale/abstractions/image_registry.jac,sha256=UCOwwuts5e1emsdDIlHJOiyW2ePA7k7D8UJdzd2kN10,1386
|
|
@@ -22,17 +23,18 @@ jac_scale/factories/deployment_factory.jac,sha256=1pzQ2HkqpKaeoycbRxdZ05McgTLpgj
|
|
|
22
23
|
jac_scale/factories/registry_factory.jac,sha256=qWgwEH75EtjXGj3d9NGeg7D9Yx2S_RW9udlCFvOOCqU,1210
|
|
23
24
|
jac_scale/factories/storage_factory.jac,sha256=FVlx-f1BOq6H1yLWXT7OLSG-ZCENvlBPNr0OwFdo-VU,2661
|
|
24
25
|
jac_scale/factories/utility_factory.jac,sha256=64gMv_N_pFeq5E7z3gXrLIqeRGS_RGIF5eLT8RxNl_I,1262
|
|
25
|
-
jac_scale/impl/config_loader.impl.jac,sha256=
|
|
26
|
-
jac_scale/impl/context.impl.jac,sha256=
|
|
26
|
+
jac_scale/impl/config_loader.impl.jac,sha256=gIcOowYuoMvpilRK6vhAGf5ULYttrkh19fB5zP7KTdI,5668
|
|
27
|
+
jac_scale/impl/context.impl.jac,sha256=3M33iA9L2SCIMT0rQTvb8MkveNCuTEjc7rNknK4Hruk,1071
|
|
27
28
|
jac_scale/impl/memory_hierarchy.main.impl.jac,sha256=Jc4Cy-5cy0TtK2uQ6rdYruxa0ZfZKSYgo3jOoXv4ER4,2339
|
|
28
29
|
jac_scale/impl/memory_hierarchy.mongo.impl.jac,sha256=APh7YloOj7bWGtv6DTBm7ekX13w_fectbZiFY0btwWU,6626
|
|
29
30
|
jac_scale/impl/memory_hierarchy.redis.impl.jac,sha256=x27TzeBI87vz72s7esQvFz8EPk3EfhVMNKU8sP4s2hk,4900
|
|
30
|
-
jac_scale/impl/serve.impl.jac,sha256=
|
|
31
|
+
jac_scale/impl/serve.impl.jac,sha256=zzWAF0PukDMdKKFmiwP-cupxrCA8YtZ_leYY-Qzcnkc,85767
|
|
31
32
|
jac_scale/impl/user_manager.impl.jac,sha256=7xGoLMweQuoVW-na7XbK3bctfUHaSEnT9nB7DLgSlgw,11817
|
|
33
|
+
jac_scale/impl/webhook.impl.jac,sha256=87IZn9nfSaNXX9RYXVJRJEz-3xzznpC6RhVBoJJA2X8,7504
|
|
32
34
|
jac_scale/jserver/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
35
|
jac_scale/jserver/jfast_api.jac,sha256=Ez7EZ883TS9IIACzK8QMqTgVTtNoaOenbmwNKnekBuc,5561
|
|
34
36
|
jac_scale/jserver/jserver.jac,sha256=8gWh22nX9Z5rJp3D1AxOSU8lBhoX4-kBZ0uvJPPNqX8,3356
|
|
35
|
-
jac_scale/jserver/impl/jfast_api.impl.jac,sha256=
|
|
37
|
+
jac_scale/jserver/impl/jfast_api.impl.jac,sha256=bnxoReOOntQL0FHDgLSoaXtQRtCYOOEVWhq-lAXeqI0,26931
|
|
36
38
|
jac_scale/jserver/impl/jserver.impl.jac,sha256=4NOzfys4WQmGt6vttTTh6By12hEJqdvOzCZyCBsUQKk,2224
|
|
37
39
|
jac_scale/providers/database/kubernetes_mongo.jac,sha256=nP43b-ePR8XInhhk7gzdvJvOJMZcTZanm1VALdiCqXA,4952
|
|
38
40
|
jac_scale/providers/database/kubernetes_redis.jac,sha256=qF9HkNLLgiymxiCXbmETbVVDs5Wqs1m4s3O6xFR7jRA,3778
|
|
@@ -51,18 +53,18 @@ jac_scale/tests/test_file_upload.py,sha256=JooF-9rgwOqtNkWRfvfHu4W5x-gzk91bHVi13
|
|
|
51
53
|
jac_scale/tests/test_hooks.py,sha256=TzikM6dd1uKg1G2L1MtgXYzzjY6YhrbAQXZah-TO33Y,1366
|
|
52
54
|
jac_scale/tests/test_k8s_utils.py,sha256=Oqocwl4m2IFgM27Lgsi96WJeHS8hnx9WDOcxq9YkqJU,4613
|
|
53
55
|
jac_scale/tests/test_memory_hierarchy.py,sha256=q_DsKyPIuQymab_sEtXojDW0GOyunRxZJsNTJDrGA7s,8324
|
|
54
|
-
jac_scale/tests/test_restspec.py,sha256=
|
|
55
|
-
jac_scale/tests/test_serve.py,sha256=
|
|
56
|
+
jac_scale/tests/test_restspec.py,sha256=fYNpQi7ZhV7KnSoJQ4xH1tJIQeayaeCvFS5GBLAZO1o,10853
|
|
57
|
+
jac_scale/tests/test_serve.py,sha256=xKPjQJLdtWaCISlP3Kirf3A4i1Fxubej_h5oLklKSY8,81244
|
|
56
58
|
jac_scale/tests/test_sso.py,sha256=9h2AUC9JBM74O6xfpyIcl98iXyZWcDIGdKRtlb_5uqo,28104
|
|
57
59
|
jac_scale/tests/test_storage.py,sha256=Y7CIbKrH3OmJQlBeV9A965-7TtxL6nuZtfkfoeqIeS4,10514
|
|
58
|
-
jac_scale/tests/fixtures/test_api.jac,sha256=
|
|
59
|
-
jac_scale/tests/fixtures/test_restspec.jac,sha256=
|
|
60
|
+
jac_scale/tests/fixtures/test_api.jac,sha256=aqfvy4wgc0qPTM9_NYvXUcpemNTSdwW0tp432sdHZI4,6680
|
|
61
|
+
jac_scale/tests/fixtures/test_restspec.jac,sha256=oFqACGtuoGz1xsURAaeXrQe7hkMI-W8hL5IKFDTQHKU,2511
|
|
60
62
|
jac_scale/tests/fixtures/todo_app.jac,sha256=beI6AiRutmXsXxuzU9nIZTE-AuoBBP-WqbRA2ux1pN4,1216
|
|
61
63
|
jac_scale/tests/fixtures/scale-feats/main.jac,sha256=QN8Opodha9jLWdRGCASByIogePr1XKphgKjyE9Pp73c,4205
|
|
62
64
|
jac_scale/tests/fixtures/scale-feats/components/Button.cl.jac,sha256=e8tvNzW_QikFJMSTcbkziKP69NF00DqQVxcjLPkAW-w,903
|
|
63
65
|
jac_scale/utilities/loggers/standard_logger.jac,sha256=6XL5ETAOBwbsFCOp0VN_7TOnqcQDmbLZVzubA-JR3vA,1376
|
|
64
|
-
jac_scale-0.1.
|
|
65
|
-
jac_scale-0.1.
|
|
66
|
-
jac_scale-0.1.
|
|
67
|
-
jac_scale-0.1.
|
|
68
|
-
jac_scale-0.1.
|
|
66
|
+
jac_scale-0.1.4.dist-info/METADATA,sha256=F_sIgqYZVaPGz7cPpx-Sc-EqI0cNX43nEkFWQiUjU5w,20491
|
|
67
|
+
jac_scale-0.1.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
68
|
+
jac_scale-0.1.4.dist-info/entry_points.txt,sha256=n-Wm8JEtGOqy_IY_kgIOi3-uYnuVK-iWsvKiLkxlG4E,105
|
|
69
|
+
jac_scale-0.1.4.dist-info/top_level.txt,sha256=PpgR0R8z9qoFbSser2K20r5Is4K6TxVwguoN6LfTEKU,10
|
|
70
|
+
jac_scale-0.1.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|