infrasynth-backend-kit/tests/test_configs/test_views.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- 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)
2026-09-29 17:06:54 -05:00

168 lines
7.8 KiB
Python

"""Config API endpoints, permissions, and masking."""
import pytest
from rest_framework import status
from infrasynth.configs.models import ConfigValue
from infrasynth.configs.registry import ConfigRegistry, ConfigType
from infrasynth.security.models import Role
CONFIGS_URL = "/api/v1/configs/"
DEFINITIONS_URL = "/api/v1/configs/definitions/"
@pytest.fixture(autouse=True)
def clean_registry(clean_config_registry):
yield
@pytest.fixture(autouse=True)
def envelope_errors(settings):
settings.REST_FRAMEWORK = {
**settings.REST_FRAMEWORK,
"EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
}
def _values(response):
return {entry["key"]: entry["value"] for entry in response.json()["values"]}
def _grant_permissions(user, *codenames):
role = Role.objects.create(name="Config Manager", slug=f"cfg-mgr-{user.pk}", permissions=list(codenames))
role.users.add(user)
class TestConfigListView:
def test_lists_effective_values(self, authenticated_client):
ConfigRegistry.register("branding.color", type=ConfigType.STRING, default="#000")
response = authenticated_client.get(CONFIGS_URL)
assert response.status_code == status.HTTP_200_OK
assert _values(response)["branding.color"] == "#000"
def test_group_filter(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding")
ConfigRegistry.register("b", type=ConfigType.INT, default=2, group="limits")
response = authenticated_client.get(f"{CONFIGS_URL}?group=branding")
assert _values(response) == {"a": 1}
def test_keys_filter(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
ConfigRegistry.register("b", type=ConfigType.INT, default=2)
response = authenticated_client.get(f"{CONFIGS_URL}?keys=a")
assert _values(response) == {"a": 1}
def test_secrets_masked(self, authenticated_client, tenant):
from infrasynth.configs.services import ConfigService
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
ConfigService().set("api.token", "hunter2", tenant=tenant)
response = authenticated_client.get(CONFIGS_URL)
assert _values(response)["api.token"] is None
def test_requires_auth(self, api_client):
assert api_client.get(CONFIGS_URL).status_code == status.HTTP_401_UNAUTHORIZED
class TestConfigDetailView:
def test_get_returns_value_and_metadata(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding", label="A")
response = authenticated_client.get(f"{CONFIGS_URL}a/")
assert response.status_code == status.HTTP_200_OK
assert response.data["value"] == 1
assert response.data["type"] == "int"
assert response.data["is_overridden"] is False
def test_put_sets_tenant_override(self, authenticated_client, tenant):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = authenticated_client.put(f"{CONFIGS_URL}a/", {"value": 9}, format="json")
assert response.status_code == status.HTTP_200_OK
assert ConfigValue.all_objects.get(tenant=tenant, key="a").value == 9
assert response.data["is_overridden"] is True
def test_put_invalid_value_is_400(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = authenticated_client.put(f"{CONFIGS_URL}a/", {"value": "nope"}, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data["code"] == "VALIDATION_CONFIG_INVALID"
def test_delete_resets_override(self, authenticated_client, tenant):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
authenticated_client.put(f"{CONFIGS_URL}a/", {"value": 9}, format="json")
response = authenticated_client.delete(f"{CONFIGS_URL}a/")
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not ConfigValue.all_objects.filter(tenant=tenant, key="a").exists()
def test_unknown_key_is_404(self, authenticated_client):
assert authenticated_client.get(f"{CONFIGS_URL}missing/").status_code == status.HTTP_404_NOT_FOUND
assert (
authenticated_client.put(f"{CONFIGS_URL}missing/", {"value": 1}, format="json").status_code
== status.HTTP_404_NOT_FOUND
)
def test_secret_value_masked_on_get(self, authenticated_client, tenant):
from infrasynth.configs.services import ConfigService
ConfigRegistry.register("api.token", type=ConfigType.STRING, is_secret=True)
ConfigService().set("api.token", "hunter2", tenant=tenant)
response = authenticated_client.get(f"{CONFIGS_URL}api.token/")
assert response.data["value"] is None
def test_requires_auth(self, api_client):
ConfigRegistry.register("a", default=1)
assert api_client.get(f"{CONFIGS_URL}a/").status_code == status.HTTP_401_UNAUTHORIZED
class TestConfigPermissions:
def test_non_owner_without_permission_is_403(self, member_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = member_client.put(f"{CONFIGS_URL}a/", {"value": 2}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_non_owner_with_permission_can_write(self, member_client, member_user):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
_grant_permissions(member_user, "configs.manage")
assert member_client.put(f"{CONFIGS_URL}a/", {"value": 2}, format="json").status_code == status.HTTP_200_OK
def test_owner_can_write(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
response = authenticated_client.put(f"{CONFIGS_URL}a/", {"value": 2}, format="json")
assert response.status_code == status.HTTP_200_OK
def test_global_write_requires_manage_global(self, member_client, member_user):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
assert (
member_client.put(f"{CONFIGS_URL}global/a/", {"value": 2}, format="json").status_code
== status.HTTP_403_FORBIDDEN
)
_grant_permissions(member_user, "configs.manage_global")
assert (
member_client.put(f"{CONFIGS_URL}global/a/", {"value": 2}, format="json").status_code == status.HTTP_200_OK
)
def test_global_writes_can_be_disabled(self, authenticated_client, settings):
ConfigRegistry.register("a", type=ConfigType.INT, default=1)
settings.INFRASYNTH_CONFIGS = {**settings.INFRASYNTH_CONFIGS, "ALLOW_GLOBAL_WRITES": False}
response = authenticated_client.put(f"{CONFIGS_URL}global/a/", {"value": 2}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data["code"] == "AUTH_CONFIG_GLOBAL_WRITES_DISABLED"
class TestDefinitionsView:
def test_lists_registered_schema(self, authenticated_client):
ConfigRegistry.register("a", type=ConfigType.INT, default=1, group="branding", label="A")
response = authenticated_client.get(DEFINITIONS_URL)
assert response.status_code == status.HTTP_200_OK
entry = response.json()["definitions"][0]
assert entry["key"] == "a"
assert entry["type"] == "int"
assert entry["default"] == 1
assert entry["group"] == "branding"
def test_secret_default_masked(self, authenticated_client):
ConfigRegistry.register("api.token", type=ConfigType.STRING, default="d", is_secret=True)
entry = authenticated_client.get(DEFINITIONS_URL).json()["definitions"][0]
assert entry["default"] is None
def test_requires_auth(self, api_client):
assert api_client.get(DEFINITIONS_URL).status_code == status.HTTP_401_UNAUTHORIZED