Skip to content

Commit fb99090

Browse files
committed
Parity
1 parent 418ff61 commit fb99090

2 files changed

Lines changed: 250 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ if __name__ == "__main__":
7373

7474
## Error codes the server can return
7575

76-
invalid_app, invalid_key, expired, revoked, hwid_mismatch, no_credits, blocked, rate_limited, replay_detected, checksum_required, checksum_mismatch, session_expired, app_disabled
76+
invalid_app, invalid_key, expired, revoked, hwid_mismatch, no_credits, blocked, rate_limited, replay_detected, session_expired, app_disabled, bad_request
7777

7878
## Common patterns
7979

test_authforge.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"""Unit tests for the AuthForge Python SDK (stdlib unittest + mock only)."""
2+
3+
from __future__ import annotations
4+
5+
import base64
6+
import hashlib
7+
import hmac
8+
import json
9+
import unittest
10+
from pathlib import Path
11+
from unittest.mock import MagicMock, patch
12+
13+
from authforge import AuthForgeClient
14+
15+
16+
def _load_test_vectors() -> dict:
17+
path = Path(__file__).resolve().parent / "test_vectors.json"
18+
with path.open(encoding="utf-8") as f:
19+
return json.load(f)
20+
21+
22+
class CryptoTests(unittest.TestCase):
23+
"""Verify key derivation and HMAC signing against test_vectors.json."""
24+
25+
@classmethod
26+
def setUpClass(cls) -> None:
27+
cls.vectors = _load_test_vectors()
28+
29+
def test_derive_signing_key_matches_vectors(self) -> None:
30+
app_secret = self.vectors["inputs"]["appSecret"]
31+
nonce = self.vectors["inputs"]["nonce"]
32+
expected_hex = self.vectors["outputs"]["derivedKeyHex"]
33+
34+
client = AuthForgeClient(
35+
"test-app-id",
36+
app_secret,
37+
"LOCAL",
38+
heartbeat_interval=86400,
39+
)
40+
derived = client._derive_key(nonce)
41+
self.assertEqual(derived.hex(), expected_hex)
42+
43+
# Documented algorithm: SHA256(secret + nonce) digest
44+
seed = f"{app_secret}{nonce}".encode("utf-8")
45+
self.assertEqual(hashlib.sha256(seed).digest(), derived)
46+
47+
def test_sign_payload_matches_vectors(self) -> None:
48+
"""HMAC-SHA256(base64 payload string, derived key) == signatureHex."""
49+
vectors = self.vectors
50+
key = bytes.fromhex(vectors["outputs"]["derivedKeyHex"])
51+
payload_b64 = vectors["inputs"]["payload"]
52+
expected_sig = vectors["outputs"]["signatureHex"]
53+
54+
actual = hmac.new(
55+
key,
56+
payload_b64.encode("utf-8"),
57+
hashlib.sha256,
58+
).hexdigest()
59+
self.assertEqual(actual, expected_sig)
60+
61+
client = AuthForgeClient(
62+
"test-app-id",
63+
vectors["inputs"]["appSecret"],
64+
"LOCAL",
65+
heartbeat_interval=86400,
66+
)
67+
client._verify_signature(payload_b64, key, expected_sig)
68+
69+
def test_verify_signature_rejects_tampered_payload(self) -> None:
70+
vectors = self.vectors
71+
key = bytes.fromhex(vectors["outputs"]["derivedKeyHex"])
72+
payload_b64 = vectors["inputs"]["payload"]
73+
bad_payload = payload_b64[:-1] + ("a" if payload_b64[-1] != "a" else "b")
74+
75+
client = AuthForgeClient(
76+
"test-app-id",
77+
vectors["inputs"]["appSecret"],
78+
"LOCAL",
79+
heartbeat_interval=86400,
80+
)
81+
with self.assertRaises(ValueError) as ctx:
82+
client._verify_signature(bad_payload, key, vectors["outputs"]["signatureHex"])
83+
self.assertEqual(ctx.exception.args[0], "signature_mismatch")
84+
85+
86+
class HwidTests(unittest.TestCase):
87+
def test_hwid_is_64_char_hex_sha256(self) -> None:
88+
with (
89+
patch.object(AuthForgeClient, "_safe_mac_address", return_value="001122334455"),
90+
patch.object(AuthForgeClient, "_safe_cpu_info", return_value="test-cpu"),
91+
patch.object(AuthForgeClient, "_safe_disk_serial", return_value="test-disk"),
92+
):
93+
client = AuthForgeClient(
94+
"app-id",
95+
"app-secret",
96+
"LOCAL",
97+
heartbeat_interval=86400,
98+
)
99+
100+
hwid = client._hwid
101+
self.assertEqual(len(hwid), 64)
102+
self.assertTrue(all(c in "0123456789abcdef" for c in hwid))
103+
104+
material = "mac:001122334455|cpu:test-cpu|disk:test-disk"
105+
expected = hashlib.sha256(material.encode("utf-8")).hexdigest()
106+
self.assertEqual(hwid, expected)
107+
108+
109+
class NonceTests(unittest.TestCase):
110+
def test_nonces_are_unique_and_sufficient_length(self) -> None:
111+
client = AuthForgeClient(
112+
"app-id",
113+
"app-secret",
114+
"LOCAL",
115+
heartbeat_interval=86400,
116+
)
117+
seen: set[str] = set()
118+
for _ in range(200):
119+
n = client._generate_nonce()
120+
self.assertGreaterEqual(len(n), 8)
121+
self.assertTrue(all(c in "0123456789abcdef" for c in n))
122+
seen.add(n)
123+
self.assertEqual(len(seen), 200)
124+
125+
126+
class ClientStateTests(unittest.TestCase):
127+
def test_not_authenticated_before_login(self) -> None:
128+
client = AuthForgeClient(
129+
"app-id",
130+
"app-secret",
131+
"LOCAL",
132+
heartbeat_interval=86400,
133+
)
134+
self.assertFalse(client.is_authenticated())
135+
self.assertIsNone(client.get_session_data())
136+
self.assertIsNone(client.get_app_variables())
137+
self.assertIsNone(client.get_license_variables())
138+
139+
def test_logout_clears_authentication_state(self) -> None:
140+
vectors = _load_test_vectors()
141+
app_secret = vectors["inputs"]["appSecret"]
142+
nonce = vectors["inputs"]["nonce"]
143+
payload_b64 = vectors["inputs"]["payload"]
144+
signature_hex = vectors["outputs"]["signatureHex"]
145+
146+
mock_resp = MagicMock()
147+
mock_resp.read.return_value = json.dumps(
148+
{
149+
"status": "ok",
150+
"payload": payload_b64,
151+
"signature": signature_hex,
152+
},
153+
separators=(",", ":"),
154+
).encode("utf-8")
155+
156+
urlopen_cm = MagicMock()
157+
urlopen_cm.__enter__.return_value = mock_resp
158+
urlopen_cm.__exit__.return_value = None
159+
160+
with (
161+
patch("authforge.urllib.request.urlopen", return_value=urlopen_cm),
162+
patch.object(AuthForgeClient, "_generate_nonce", return_value=nonce),
163+
):
164+
client = AuthForgeClient(
165+
"app-id",
166+
app_secret,
167+
"LOCAL",
168+
heartbeat_interval=86400,
169+
)
170+
self.assertTrue(client.login("license-key"))
171+
172+
self.assertTrue(client.is_authenticated())
173+
self.assertIsNotNone(client.get_session_data())
174+
175+
client.logout()
176+
self.assertFalse(client.is_authenticated())
177+
self.assertIsNone(client.get_session_data())
178+
self.assertIsNone(client.get_app_variables())
179+
self.assertIsNone(client.get_license_variables())
180+
181+
182+
class HeartbeatModeTests(unittest.TestCase):
183+
def test_invalid_heartbeat_mode_rejected(self) -> None:
184+
for bad in ("", "OFF", "both", "server-only", None):
185+
with self.subTest(mode=bad):
186+
with self.assertRaises(ValueError) as ctx:
187+
AuthForgeClient("app-id", "secret", bad) # type: ignore[arg-type]
188+
self.assertIn("heartbeat_mode", ctx.exception.args[0].lower())
189+
190+
def test_valid_heartbeat_modes_accepted(self) -> None:
191+
for mode in ("LOCAL", "SERVER", "local", "server"):
192+
with self.subTest(mode=mode):
193+
c = AuthForgeClient("app-id", "secret", mode, heartbeat_interval=1)
194+
self.assertIn(c.heartbeat_mode, ("LOCAL", "SERVER"))
195+
196+
197+
class MockLoginTests(unittest.TestCase):
198+
def test_login_with_mocked_http_succeeds_and_exposes_session(self) -> None:
199+
vectors = _load_test_vectors()
200+
app_secret = vectors["inputs"]["appSecret"]
201+
nonce = vectors["inputs"]["nonce"]
202+
payload_b64 = vectors["inputs"]["payload"]
203+
signature_hex = vectors["outputs"]["signatureHex"]
204+
205+
inner = json.loads(base64.urlsafe_b64decode(_padded_b64(payload_b64)))
206+
self.assertEqual(inner.get("nonce"), nonce)
207+
208+
mock_resp = MagicMock()
209+
mock_resp.read.return_value = json.dumps(
210+
{
211+
"status": "ok",
212+
"payload": payload_b64,
213+
"signature": signature_hex,
214+
},
215+
separators=(",", ":"),
216+
).encode("utf-8")
217+
218+
cm = MagicMock()
219+
cm.__enter__.return_value = mock_resp
220+
cm.__exit__.return_value = None
221+
222+
with (
223+
patch("authforge.urllib.request.urlopen", return_value=cm),
224+
patch.object(AuthForgeClient, "_generate_nonce", return_value=nonce),
225+
):
226+
client = AuthForgeClient(
227+
"vector-app",
228+
app_secret,
229+
"LOCAL",
230+
heartbeat_interval=86400,
231+
)
232+
ok = client.login("test-license")
233+
234+
self.assertTrue(ok)
235+
self.assertTrue(client.is_authenticated())
236+
session = client.get_session_data()
237+
self.assertIsNotNone(session)
238+
assert session is not None
239+
self.assertEqual(session.get("nonce"), nonce)
240+
self.assertIn("sessionToken", session)
241+
242+
243+
def _padded_b64(value: str) -> str:
244+
pad = (4 - len(value) % 4) % 4
245+
return value + ("=" * pad)
246+
247+
248+
if __name__ == "__main__":
249+
unittest.main()

0 commit comments

Comments
 (0)