Extract Layer 1 to cores/mizan-python (mizan-core)
Pull cache/keys.py (HMAC cache key derivation) and mwt.py (Mizan Web Token) out of backends/mizan-django and into a new cores/mizan-python package. mizan-django re-imports them via the new mizan_core module. Naming: directory cores/mizan-python/, distribution mizan-core, importable module mizan_core. mizan-django keeps its existing 'mizan' distribution slot on PyPI; the two coexist as distinct packages. Wiring: - backends/mizan-django/pyproject.toml gains a 'mizan-core' dep with a [tool.uv.sources] path entry (editable install from ../../cores/mizan-python). - Makefile install target prepends 'cd cores/mizan-python && uv pip install -e .' - 3 import sites in mizan-django updated: cache/__init__.py, jwt/functions.py, client/executor.py — all now import from mizan_core. Test split: - 3 unit-test classes (CacheKeyDerivationTests, MWTCreationTests, PermissionKeyTests) move to cores/mizan-python/tests/, rewritten against unittest.TestCase (no Django dep). The cross-language pin test (pinned HMAC hex digests against mizan-ts) moves with CacheKeyDerivationTests. - Integration tests stay in mizan-django (CacheBackendTests, CachePurgeTests, CacheIntegrationTests, RevParameterTests, MWTAuthIntegrationTests) — they need the Django request flow. Verified: - mizan-core: 15/15 pass (incl. cross-language pin) - mizan-django: 348 pass, 21 skip, 0 fail - mizan-ts: edge-compat 34/34 pass — protocol invariant holds, the moved Python derive_cache_key still produces the exact hex digests TS pins against. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
Makefile
1
Makefile
@@ -6,6 +6,7 @@ REACT = frontends/mizan-react
|
|||||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
install:
|
install:
|
||||||
|
cd cores/mizan-python && uv pip install -e .
|
||||||
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
cd $(DJANGO) && uv pip install -e ".[dev,channels]"
|
||||||
cd $(REACT) && npm install
|
cd $(REACT) && npm install
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description = "Django + React server functions framework"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"mizan-core",
|
||||||
"django>=5.0",
|
"django>=5.0",
|
||||||
"django-ninja>=1.0",
|
"django-ninja>=1.0",
|
||||||
"django-readers>=2.0",
|
"django-readers>=2.0",
|
||||||
@@ -12,6 +13,9 @@ dependencies = [
|
|||||||
"PyJWT>=2.0",
|
"PyJWT>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
mizan-core = { path = "../../cores/mizan-python", editable = true }
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
cache = [
|
cache = [
|
||||||
"redis>=5.0",
|
"redis>=5.0",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import threading
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .backend import CacheBackend, MemoryCache, RedisCache
|
from .backend import CacheBackend, MemoryCache, RedisCache
|
||||||
from .keys import derive_cache_key, CONTEXT_KEY_PREFIX
|
from mizan_core.cache.keys import derive_cache_key, CONTEXT_KEY_PREFIX
|
||||||
|
|
||||||
logger = logging.getLogger("mizan.cache")
|
logger = logging.getLogger("mizan.cache")
|
||||||
|
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ def _try_mwt_auth(request: HttpRequest) -> bool:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from mizan.mwt import decode_mwt, MWTUser
|
from mizan_core.mwt import decode_mwt, MWTUser
|
||||||
|
|
||||||
payload = decode_mwt(token, settings.mwt_secret)
|
payload = decode_mwt(token, settings.mwt_secret)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from mizan.client import client
|
from mizan.client import client
|
||||||
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
from mizan.jwt.tokens import create_token_pair, refresh_tokens
|
||||||
from mizan.mwt import create_mwt
|
from mizan_core.mwt import create_mwt
|
||||||
|
|
||||||
|
|
||||||
class TokenPairOutput(BaseModel):
|
class TokenPairOutput(BaseModel):
|
||||||
|
|||||||
@@ -2778,83 +2778,6 @@ class PrivateAndRouteTests(TestCase):
|
|||||||
# ── Cache conformance tests ────────────────────────────────────────────────
|
# ── Cache conformance tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class CacheKeyDerivationTests(TestCase):
|
|
||||||
"""Tests that HMAC cache key derivation is deterministic and correct."""
|
|
||||||
|
|
||||||
SECRET = "test-cache-secret"
|
|
||||||
|
|
||||||
def test_deterministic_output(self):
|
|
||||||
"""Same inputs always produce the same key."""
|
|
||||||
from mizan.cache.keys import derive_cache_key
|
|
||||||
|
|
||||||
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
|
|
||||||
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
|
|
||||||
self.assertEqual(key1, key2)
|
|
||||||
self.assertTrue(key1.startswith("ctx:user:"))
|
|
||||||
self.assertEqual(len(key1), len("ctx:user:") + 64) # prefix + SHA-256 hex
|
|
||||||
|
|
||||||
def test_param_order_irrelevant(self):
|
|
||||||
"""Parameter ordering does not affect the key."""
|
|
||||||
from mizan.cache.keys import derive_cache_key
|
|
||||||
|
|
||||||
key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"})
|
|
||||||
key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"})
|
|
||||||
self.assertEqual(key1, key2)
|
|
||||||
|
|
||||||
def test_different_user_ids_different_keys(self):
|
|
||||||
"""Different user_ids produce different cache keys."""
|
|
||||||
from mizan.cache.keys import derive_cache_key
|
|
||||||
|
|
||||||
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5")
|
|
||||||
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6")
|
|
||||||
self.assertNotEqual(key1, key2)
|
|
||||||
|
|
||||||
def test_rev_changes_key(self):
|
|
||||||
"""Different rev values produce different cache keys."""
|
|
||||||
from mizan.cache.keys import derive_cache_key
|
|
||||||
|
|
||||||
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0)
|
|
||||||
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1)
|
|
||||||
self.assertNotEqual(key1, key2)
|
|
||||||
|
|
||||||
def test_no_delimiter_collision(self):
|
|
||||||
"""JSON-canonical form prevents delimiter-free concatenation collisions."""
|
|
||||||
from mizan.cache.keys import derive_cache_key
|
|
||||||
|
|
||||||
# "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3"
|
|
||||||
key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12")
|
|
||||||
key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2")
|
|
||||||
self.assertNotEqual(key1, key2)
|
|
||||||
|
|
||||||
def test_public_vs_user_scoped(self):
|
|
||||||
"""Public (no user_id) and user-scoped produce different keys."""
|
|
||||||
from mizan.cache.keys import derive_cache_key
|
|
||||||
|
|
||||||
public = derive_cache_key(self.SECRET, "products", {"id": "1"})
|
|
||||||
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
|
|
||||||
self.assertNotEqual(public, scoped)
|
|
||||||
|
|
||||||
def test_cross_language_pin(self):
|
|
||||||
"""Pinned HMAC values — must match TypeScript adapter exactly."""
|
|
||||||
from mizan.cache.keys import derive_cache_key
|
|
||||||
|
|
||||||
pin_secret = "test-pin-secret-that-is-32bytes!"
|
|
||||||
|
|
||||||
public_key = derive_cache_key(pin_secret, "user", {"user_id": "5"}, rev=0)
|
|
||||||
self.assertEqual(
|
|
||||||
public_key,
|
|
||||||
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6",
|
|
||||||
)
|
|
||||||
|
|
||||||
user_scoped_key = derive_cache_key(
|
|
||||||
pin_secret, "user", {"user_id": "5"}, user_id="5", rev=0,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
user_scoped_key,
|
|
||||||
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CacheBackendTests(TestCase):
|
class CacheBackendTests(TestCase):
|
||||||
"""Tests for MemoryCache backend operations."""
|
"""Tests for MemoryCache backend operations."""
|
||||||
|
|
||||||
@@ -3081,7 +3004,7 @@ class RevParameterTests(TestCase):
|
|||||||
|
|
||||||
def test_rev_changes_cache_key(self):
|
def test_rev_changes_cache_key(self):
|
||||||
"""Different rev values produce different HMAC cache keys."""
|
"""Different rev values produce different HMAC cache keys."""
|
||||||
from mizan.cache.keys import derive_cache_key
|
from mizan_core.cache.keys import derive_cache_key
|
||||||
|
|
||||||
key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0)
|
key_v0 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=0)
|
||||||
key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1)
|
key_v1 = derive_cache_key("secret", "ctx", {"id": "1"}, rev=1)
|
||||||
@@ -3214,7 +3137,7 @@ class CachePolicyIntegrationTests(TestCase):
|
|||||||
|
|
||||||
def test_effective_rev_is_maximum(self):
|
def test_effective_rev_is_maximum(self):
|
||||||
"""Context with mixed revs uses the maximum for cache key."""
|
"""Context with mixed revs uses the maximum for cache key."""
|
||||||
from mizan.cache.keys import derive_cache_key
|
from mizan_core.cache.keys import derive_cache_key
|
||||||
from mizan.cache import set_cache, reset_cache
|
from mizan.cache import set_cache, reset_cache
|
||||||
from mizan.cache.backend import MemoryCache
|
from mizan.cache.backend import MemoryCache
|
||||||
from mizan.setup.settings import clear_settings_cache
|
from mizan.setup.settings import clear_settings_cache
|
||||||
@@ -3252,115 +3175,6 @@ class CachePolicyIntegrationTests(TestCase):
|
|||||||
# ── MWT (Mizan Web Token) tests ────────────────────────────────────────────
|
# ── MWT (Mizan Web Token) tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class MWTCreationTests(TestCase):
|
|
||||||
"""Tests for MWT creation and decoding."""
|
|
||||||
|
|
||||||
SECRET = "test-mwt-secret-that-is-32bytes!"
|
|
||||||
|
|
||||||
def _make_user(self, **kwargs):
|
|
||||||
user = MagicMock()
|
|
||||||
user.pk = kwargs.get("pk", 1)
|
|
||||||
user.is_staff = kwargs.get("is_staff", False)
|
|
||||||
user.is_superuser = kwargs.get("is_superuser", False)
|
|
||||||
user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set()))
|
|
||||||
return user
|
|
||||||
|
|
||||||
def test_create_and_decode(self):
|
|
||||||
"""Create an MWT and decode it successfully."""
|
|
||||||
from mizan.mwt import create_mwt, decode_mwt
|
|
||||||
|
|
||||||
user = self._make_user(pk=42, is_staff=True)
|
|
||||||
token = create_mwt(user, self.SECRET, ttl=300)
|
|
||||||
payload = decode_mwt(token, self.SECRET)
|
|
||||||
|
|
||||||
self.assertIsNotNone(payload)
|
|
||||||
self.assertEqual(payload.sub, "42")
|
|
||||||
self.assertTrue(payload.staff)
|
|
||||||
self.assertFalse(payload.super)
|
|
||||||
self.assertEqual(payload.kid, "v1")
|
|
||||||
self.assertEqual(len(payload.pkey), 64)
|
|
||||||
|
|
||||||
def test_decode_expired(self):
|
|
||||||
"""Expired MWT returns None."""
|
|
||||||
from mizan.mwt import create_mwt, decode_mwt
|
|
||||||
|
|
||||||
user = self._make_user()
|
|
||||||
token = create_mwt(user, self.SECRET, ttl=-1)
|
|
||||||
payload = decode_mwt(token, self.SECRET)
|
|
||||||
self.assertIsNone(payload)
|
|
||||||
|
|
||||||
def test_decode_wrong_secret(self):
|
|
||||||
"""MWT signed with wrong secret returns None."""
|
|
||||||
from mizan.mwt import create_mwt, decode_mwt
|
|
||||||
|
|
||||||
user = self._make_user()
|
|
||||||
token = create_mwt(user, self.SECRET)
|
|
||||||
payload = decode_mwt(token, "wrong-secret")
|
|
||||||
self.assertIsNone(payload)
|
|
||||||
|
|
||||||
def test_decode_wrong_audience(self):
|
|
||||||
"""MWT with wrong audience returns None."""
|
|
||||||
from mizan.mwt import create_mwt, decode_mwt
|
|
||||||
|
|
||||||
user = self._make_user()
|
|
||||||
token = create_mwt(user, self.SECRET, audience="app1")
|
|
||||||
payload = decode_mwt(token, self.SECRET, audience="app2")
|
|
||||||
self.assertIsNone(payload)
|
|
||||||
|
|
||||||
def test_mwt_user_has_pkey(self):
|
|
||||||
"""MWTUser carries the permission key."""
|
|
||||||
from mizan.mwt import create_mwt, decode_mwt, MWTUser
|
|
||||||
|
|
||||||
user = self._make_user(pk=5, perms={"app.view_thing"})
|
|
||||||
token = create_mwt(user, self.SECRET)
|
|
||||||
payload = decode_mwt(token, self.SECRET)
|
|
||||||
mwt_user = MWTUser(payload)
|
|
||||||
|
|
||||||
self.assertEqual(mwt_user.pk, 5)
|
|
||||||
self.assertTrue(mwt_user.is_authenticated)
|
|
||||||
self.assertEqual(len(mwt_user.pkey), 64)
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionKeyTests(TestCase):
|
|
||||||
"""Tests for pkey determinism and sensitivity."""
|
|
||||||
|
|
||||||
def _make_user(self, **kwargs):
|
|
||||||
user = MagicMock()
|
|
||||||
user.pk = kwargs.get("pk", 1)
|
|
||||||
user.is_staff = kwargs.get("is_staff", False)
|
|
||||||
user.is_superuser = kwargs.get("is_superuser", False)
|
|
||||||
user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set()))
|
|
||||||
return user
|
|
||||||
|
|
||||||
def test_deterministic(self):
|
|
||||||
"""Same permissions produce same pkey."""
|
|
||||||
from mizan.mwt import compute_permission_key
|
|
||||||
|
|
||||||
user = self._make_user(perms={"app.view_thing", "app.add_thing"})
|
|
||||||
pkey1 = compute_permission_key(user)
|
|
||||||
pkey2 = compute_permission_key(user)
|
|
||||||
self.assertEqual(pkey1, pkey2)
|
|
||||||
|
|
||||||
def test_changes_on_permission_change(self):
|
|
||||||
"""Different permissions produce different pkey."""
|
|
||||||
from mizan.mwt import compute_permission_key
|
|
||||||
|
|
||||||
user1 = self._make_user(perms={"app.view_thing"})
|
|
||||||
user2 = self._make_user(perms={"app.view_thing", "app.add_thing"})
|
|
||||||
self.assertNotEqual(compute_permission_key(user1), compute_permission_key(user2))
|
|
||||||
|
|
||||||
def test_changes_on_staff_change(self):
|
|
||||||
"""Staff status change produces different pkey."""
|
|
||||||
from mizan.mwt import compute_permission_key
|
|
||||||
|
|
||||||
user_normal = self._make_user(is_staff=False)
|
|
||||||
user_staff = self._make_user(is_staff=True)
|
|
||||||
self.assertNotEqual(
|
|
||||||
compute_permission_key(user_normal),
|
|
||||||
compute_permission_key(user_staff),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MWTAuthIntegrationTests(TestCase):
|
class MWTAuthIntegrationTests(TestCase):
|
||||||
"""Tests for MWT authentication in the executor."""
|
"""Tests for MWT authentication in the executor."""
|
||||||
|
|
||||||
@@ -3387,7 +3201,7 @@ class MWTAuthIntegrationTests(TestCase):
|
|||||||
|
|
||||||
def test_mwt_auth_via_header(self):
|
def test_mwt_auth_via_header(self):
|
||||||
"""Request with valid X-Mizan-Token authenticates."""
|
"""Request with valid X-Mizan-Token authenticates."""
|
||||||
from mizan.mwt import create_mwt
|
from mizan_core.mwt import create_mwt
|
||||||
from mizan.client.executor import _try_mwt_auth
|
from mizan.client.executor import _try_mwt_auth
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
|
|||||||
26
cores/mizan-python/pyproject.toml
Normal file
26
cores/mizan-python/pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[project]
|
||||||
|
name = "mizan-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Mizan Python core — HMAC cache keys, MWT identity. Framework-agnostic primitives shared by every Python backend adapter."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"PyJWT>=2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mizan_core"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_classes = ["*Tests", "*Test", "Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
0
cores/mizan-python/src/mizan_core/__init__.py
Normal file
0
cores/mizan-python/src/mizan_core/__init__.py
Normal file
0
cores/mizan-python/src/mizan_core/cache/__init__.py
vendored
Normal file
0
cores/mizan-python/src/mizan_core/cache/__init__.py
vendored
Normal file
0
cores/mizan-python/tests/__init__.py
Normal file
0
cores/mizan-python/tests/__init__.py
Normal file
68
cores/mizan-python/tests/test_keys.py
Normal file
68
cores/mizan-python/tests/test_keys.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Unit tests for cache key derivation. Includes the cross-language pin against mizan-ts."""
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from mizan_core.cache.keys import derive_cache_key
|
||||||
|
|
||||||
|
|
||||||
|
class CacheKeyDerivationTests(TestCase):
|
||||||
|
"""Tests that HMAC cache key derivation is deterministic and correct."""
|
||||||
|
|
||||||
|
SECRET = "test-cache-secret"
|
||||||
|
|
||||||
|
def test_deterministic_output(self):
|
||||||
|
"""Same inputs always produce the same key."""
|
||||||
|
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
|
||||||
|
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"})
|
||||||
|
self.assertEqual(key1, key2)
|
||||||
|
self.assertTrue(key1.startswith("ctx:user:"))
|
||||||
|
self.assertEqual(len(key1), len("ctx:user:") + 64) # prefix + SHA-256 hex
|
||||||
|
|
||||||
|
def test_param_order_irrelevant(self):
|
||||||
|
"""Parameter ordering does not affect the key."""
|
||||||
|
key1 = derive_cache_key(self.SECRET, "ctx", {"a": "1", "b": "2"})
|
||||||
|
key2 = derive_cache_key(self.SECRET, "ctx", {"b": "2", "a": "1"})
|
||||||
|
self.assertEqual(key1, key2)
|
||||||
|
|
||||||
|
def test_different_user_ids_different_keys(self):
|
||||||
|
"""Different user_ids produce different cache keys."""
|
||||||
|
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="5")
|
||||||
|
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, user_id="6")
|
||||||
|
self.assertNotEqual(key1, key2)
|
||||||
|
|
||||||
|
def test_rev_changes_key(self):
|
||||||
|
"""Different rev values produce different cache keys."""
|
||||||
|
key1 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=0)
|
||||||
|
key2 = derive_cache_key(self.SECRET, "user", {"user_id": "5"}, rev=1)
|
||||||
|
self.assertNotEqual(key1, key2)
|
||||||
|
|
||||||
|
def test_no_delimiter_collision(self):
|
||||||
|
"""JSON-canonical form prevents delimiter-free concatenation collisions."""
|
||||||
|
# "user" + user_id="12" + params="3" vs "user1" + user_id="2" + params="3"
|
||||||
|
key1 = derive_cache_key(self.SECRET, "user", {"id": "3"}, user_id="12")
|
||||||
|
key2 = derive_cache_key(self.SECRET, "user1", {"id": "3"}, user_id="2")
|
||||||
|
self.assertNotEqual(key1, key2)
|
||||||
|
|
||||||
|
def test_public_vs_user_scoped(self):
|
||||||
|
"""Public (no user_id) and user-scoped produce different keys."""
|
||||||
|
public = derive_cache_key(self.SECRET, "products", {"id": "1"})
|
||||||
|
scoped = derive_cache_key(self.SECRET, "products", {"id": "1"}, user_id="5")
|
||||||
|
self.assertNotEqual(public, scoped)
|
||||||
|
|
||||||
|
def test_cross_language_pin(self):
|
||||||
|
"""Pinned HMAC values — must match TypeScript adapter exactly."""
|
||||||
|
pin_secret = "test-pin-secret-that-is-32bytes!"
|
||||||
|
|
||||||
|
public_key = derive_cache_key(pin_secret, "user", {"user_id": "5"}, rev=0)
|
||||||
|
self.assertEqual(
|
||||||
|
public_key,
|
||||||
|
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_scoped_key = derive_cache_key(
|
||||||
|
pin_secret, "user", {"user_id": "5"}, user_id="5", rev=0,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
user_scoped_key,
|
||||||
|
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2",
|
||||||
|
)
|
||||||
97
cores/mizan-python/tests/test_mwt.py
Normal file
97
cores/mizan-python/tests/test_mwt.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Unit tests for MWT creation, decoding, and permission key derivation."""
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from mizan_core.mwt import (
|
||||||
|
MWTUser,
|
||||||
|
compute_permission_key,
|
||||||
|
create_mwt,
|
||||||
|
decode_mwt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(**kwargs):
|
||||||
|
user = MagicMock()
|
||||||
|
user.pk = kwargs.get("pk", 1)
|
||||||
|
user.is_staff = kwargs.get("is_staff", False)
|
||||||
|
user.is_superuser = kwargs.get("is_superuser", False)
|
||||||
|
user.get_all_permissions = MagicMock(return_value=kwargs.get("perms", set()))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class MWTCreationTests(TestCase):
|
||||||
|
"""Tests for MWT creation and decoding."""
|
||||||
|
|
||||||
|
SECRET = "test-mwt-secret-that-is-32bytes!"
|
||||||
|
|
||||||
|
def test_create_and_decode(self):
|
||||||
|
"""Create an MWT and decode it successfully."""
|
||||||
|
user = _make_user(pk=42, is_staff=True)
|
||||||
|
token = create_mwt(user, self.SECRET, ttl=300)
|
||||||
|
payload = decode_mwt(token, self.SECRET)
|
||||||
|
|
||||||
|
self.assertIsNotNone(payload)
|
||||||
|
self.assertEqual(payload.sub, "42")
|
||||||
|
self.assertTrue(payload.staff)
|
||||||
|
self.assertFalse(payload.super)
|
||||||
|
self.assertEqual(payload.kid, "v1")
|
||||||
|
self.assertEqual(len(payload.pkey), 64)
|
||||||
|
|
||||||
|
def test_decode_expired(self):
|
||||||
|
"""Expired MWT returns None."""
|
||||||
|
user = _make_user()
|
||||||
|
token = create_mwt(user, self.SECRET, ttl=-1)
|
||||||
|
payload = decode_mwt(token, self.SECRET)
|
||||||
|
self.assertIsNone(payload)
|
||||||
|
|
||||||
|
def test_decode_wrong_secret(self):
|
||||||
|
"""MWT signed with wrong secret returns None."""
|
||||||
|
user = _make_user()
|
||||||
|
token = create_mwt(user, self.SECRET)
|
||||||
|
payload = decode_mwt(token, "wrong-secret")
|
||||||
|
self.assertIsNone(payload)
|
||||||
|
|
||||||
|
def test_decode_wrong_audience(self):
|
||||||
|
"""MWT with wrong audience returns None."""
|
||||||
|
user = _make_user()
|
||||||
|
token = create_mwt(user, self.SECRET, audience="app1")
|
||||||
|
payload = decode_mwt(token, self.SECRET, audience="app2")
|
||||||
|
self.assertIsNone(payload)
|
||||||
|
|
||||||
|
def test_mwt_user_has_pkey(self):
|
||||||
|
"""MWTUser carries the permission key."""
|
||||||
|
user = _make_user(pk=5, perms={"app.view_thing"})
|
||||||
|
token = create_mwt(user, self.SECRET)
|
||||||
|
payload = decode_mwt(token, self.SECRET)
|
||||||
|
mwt_user = MWTUser(payload)
|
||||||
|
|
||||||
|
self.assertEqual(mwt_user.pk, 5)
|
||||||
|
self.assertTrue(mwt_user.is_authenticated)
|
||||||
|
self.assertEqual(len(mwt_user.pkey), 64)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionKeyTests(TestCase):
|
||||||
|
"""Tests for pkey determinism and sensitivity."""
|
||||||
|
|
||||||
|
def test_deterministic(self):
|
||||||
|
"""Same permissions produce same pkey."""
|
||||||
|
user = _make_user(perms={"app.view_thing", "app.add_thing"})
|
||||||
|
pkey1 = compute_permission_key(user)
|
||||||
|
pkey2 = compute_permission_key(user)
|
||||||
|
self.assertEqual(pkey1, pkey2)
|
||||||
|
|
||||||
|
def test_changes_on_permission_change(self):
|
||||||
|
"""Different permissions produce different pkey."""
|
||||||
|
user1 = _make_user(perms={"app.view_thing"})
|
||||||
|
user2 = _make_user(perms={"app.view_thing", "app.add_thing"})
|
||||||
|
self.assertNotEqual(compute_permission_key(user1), compute_permission_key(user2))
|
||||||
|
|
||||||
|
def test_changes_on_staff_change(self):
|
||||||
|
"""Staff status change produces different pkey."""
|
||||||
|
user_normal = _make_user(is_staff=False)
|
||||||
|
user_staff = _make_user(is_staff=True)
|
||||||
|
self.assertNotEqual(
|
||||||
|
compute_permission_key(user_normal),
|
||||||
|
compute_permission_key(user_staff),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user