- 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)
168 lines
7.8 KiB
Python
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
|