- infrasynth.configs: typed multi-tenant config store (registry, service, secrets, cache) + public config_changed/config_reset signals and API - emit the declared-but-dead signals (features flags/overrides, scheduler task_completed/task_failed, tenancy tenant_updated, audit model_changed) and per-model audit field exclusions - security: permission catalog (security_permission), Django-style model-derived AutoPermission, PermissionRegistry, RoleAssignment, global-or-tenant Grant/Revoke, catalog API - consolidate the permission surface: PermissionRegistry only (drop the settings dict), IsAuthenticatedAndPermitted aliases HybridPermission, require_permission replaced by required_permissions + require_all - packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
"""``ConfigService`` precedence, typing, secrets, and caching."""
|
|
|
|
import decimal
|
|
|
|
import pytest
|
|
|
|
from infrasynth.configs.models import ConfigValue
|
|
from infrasynth.configs.registry import ConfigRegistry, ConfigType
|
|
from infrasynth.configs.services import ConfigService
|
|
from infrasynth.shared.exceptions import AuthError, NotFoundError, ValidationAppError
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_registry(clean_config_registry):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def service():
|
|
return ConfigService()
|
|
|
|
|
|
class TestPrecedence:
|
|
def test_registry_default(self, service, tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
assert service.get("k", tenant=tenant) == "default"
|
|
|
|
def test_global_overrides_default(self, service, tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
service.set_global("k", "global")
|
|
assert service.get("k", tenant=tenant) == "global"
|
|
|
|
def test_tenant_overrides_global(self, service, tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
service.set_global("k", "global")
|
|
service.set("k", "tenant", tenant=tenant)
|
|
assert service.get("k", tenant=tenant) == "tenant"
|
|
|
|
def test_reset_falls_back_to_global(self, service, tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
service.set_global("k", "global")
|
|
service.set("k", "tenant", tenant=tenant)
|
|
assert service.reset("k", tenant=tenant) is True
|
|
assert service.get("k", tenant=tenant) == "global"
|
|
assert service.is_overridden("k", tenant=tenant) is False
|
|
|
|
def test_fail_closed_without_tenant_reads_global_only(self, service, tenant):
|
|
from infrasynth.tenancy.context import tenant_context
|
|
|
|
ConfigRegistry.register("k", type=ConfigType.STRING, default="default")
|
|
service.set("k", "tenant-only", tenant=tenant)
|
|
with tenant_context(None):
|
|
assert service.get("k") == "default"
|
|
# A registered key resolves to its registry default, not the arg.
|
|
assert service.get("k", default="fallback") == "default"
|
|
|
|
|
|
class TestUnknownKeys:
|
|
def test_unknown_key_raises(self, service, tenant):
|
|
with pytest.raises(NotFoundError):
|
|
service.get("missing", tenant=tenant)
|
|
|
|
def test_default_argument_wins(self, service, tenant):
|
|
assert service.get("missing", tenant=tenant, default=42) == 42
|
|
|
|
def test_set_unknown_key_raises(self, service, tenant):
|
|
with pytest.raises(NotFoundError):
|
|
service.set("missing", "x", tenant=tenant)
|
|
|
|
def test_set_requires_tenant(self, service, tenant):
|
|
from infrasynth.tenancy.context import tenant_context
|
|
|
|
ConfigRegistry.register("k", default="d")
|
|
with tenant_context(None), pytest.raises(ValidationAppError):
|
|
service.set("k", "x", tenant=None)
|
|
|
|
|
|
class TestTypedRoundTrips:
|
|
@pytest.mark.parametrize(
|
|
("config_type", "value", "expected"),
|
|
[
|
|
(ConfigType.STRING, "hola", "hola"),
|
|
(ConfigType.INT, 7, 7),
|
|
(ConfigType.FLOAT, 1.5, 1.5),
|
|
(ConfigType.BOOL, True, True),
|
|
(ConfigType.JSON, {"a": [1]}, {"a": [1]}),
|
|
(ConfigType.DECIMAL, "3.50", decimal.Decimal("3.50")),
|
|
(ConfigType.DURATION, "15m", 900),
|
|
],
|
|
)
|
|
def test_round_trip(self, service, tenant, config_type, value, expected):
|
|
ConfigRegistry.register("k", type=config_type)
|
|
service.set("k", value, tenant=tenant)
|
|
result = service.get("k", tenant=tenant)
|
|
assert result == expected
|
|
assert isinstance(result, type(expected))
|
|
|
|
|
|
class TestSecrets:
|
|
def test_secret_is_encrypted_at_rest_and_decrypted_on_read(self, service, tenant):
|
|
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
|
|
service.set("api.token", "hunter2", tenant=tenant)
|
|
|
|
row = ConfigValue.all_objects.get(tenant=tenant, key="api.token")
|
|
assert row.value != "hunter2"
|
|
assert "hunter2" not in str(row.value)
|
|
assert service.get("api.token", tenant=tenant) == "hunter2"
|
|
|
|
def test_secret_metadata_masks_default(self, service, tenant):
|
|
ConfigRegistry.register("api.token", type=ConfigType.STRING, default="d", is_secret=True)
|
|
meta = service.get_metadata("api.token", tenant=tenant)
|
|
assert meta["is_secret"] is True
|
|
assert meta["default"] is None
|
|
|
|
|
|
class TestCaching:
|
|
def test_second_read_is_cached_until_invalidated(self, service, tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.INT, default=0)
|
|
service.set("k", 1, tenant=tenant)
|
|
assert service.get("k", tenant=tenant) == 1
|
|
|
|
# Bypass the service: the cache must still hold the old value.
|
|
ConfigValue.all_objects.filter(tenant=tenant, key="k").update(value=2)
|
|
assert service.get("k", tenant=tenant) == 1
|
|
|
|
service.invalidate(tenant, "k")
|
|
assert service.get("k", tenant=tenant) == 2
|
|
|
|
def test_write_busts_cache(self, service, tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.INT, default=0)
|
|
service.set("k", 1, tenant=tenant)
|
|
assert service.get("k", tenant=tenant) == 1
|
|
service.set("k", 5, tenant=tenant)
|
|
assert service.get("k", tenant=tenant) == 5
|
|
|
|
def test_global_write_busts_global_fallback_cache(self, service, tenant):
|
|
ConfigRegistry.register("k", type=ConfigType.INT, default=0)
|
|
service.set_global("k", 1)
|
|
assert service.get("k", tenant=tenant) == 1
|
|
service.set_global("k", 2)
|
|
assert service.get("k", tenant=tenant) == 2
|
|
|
|
|
|
class TestGlobalWrites:
|
|
def test_allowed_by_default(self, service, tenant):
|
|
ConfigRegistry.register("k", default="d")
|
|
service.set_global("k", "global")
|
|
assert service.get("k", tenant=tenant) == "global"
|
|
|
|
def test_disabled_raises(self, service, tenant, settings):
|
|
ConfigRegistry.register("k", default="d")
|
|
settings.INFRASYNTH_CONFIGS = {**settings.INFRASYNTH_CONFIGS, "ALLOW_GLOBAL_WRITES": False}
|
|
with pytest.raises(AuthError):
|
|
service.set_global("k", "global")
|
|
|
|
|
|
class TestIntrospection:
|
|
def test_get_many_skips_unknown(self, service, tenant):
|
|
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
|
|
ConfigRegistry.register("b", type=ConfigType.INT, default=2)
|
|
assert service.get_many(["a", "b", "missing"], tenant=tenant) == {"a": 1, "b": 2}
|
|
|
|
def test_get_all_filters_by_group(self, service, tenant):
|
|
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding")
|
|
ConfigRegistry.register("b", type=ConfigType.INT, default=2, group="limits")
|
|
assert service.get_all(tenant=tenant, group="branding") == {"a": 1}
|
|
|
|
def test_get_metadata(self, service, tenant):
|
|
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding", label="A")
|
|
meta = service.get_metadata("a", tenant=tenant)
|
|
assert meta["type"] == "int"
|
|
assert meta["group"] == "branding"
|
|
assert meta["label"] == "A"
|
|
assert meta["is_overridden"] is False
|
|
service.set("a", 9, tenant=tenant)
|
|
assert service.get_metadata("a", tenant=tenant)["is_overridden"] is True
|
|
|
|
def test_metadata_unknown_key_raises(self, service, tenant):
|
|
with pytest.raises(NotFoundError):
|
|
service.get_metadata("missing", tenant=tenant)
|