from __future__ import annotations import importlib import sys from types import ModuleType from typing import Any from fastapi.testclient import TestClient from app.minio_provisioning import ( MinIOProvisioningConfig, deprovision_user_file_minio_resources, provision_user_file_minio_settings, ) from app.models import MinIOSettings class _FakeObject: def __init__(self, object_name: str) -> None: self.object_name = object_name class _FakeMinio: bucket_exists_value = True objects: list[str] = [] removed_objects: list[str] = [] made_buckets: list[str] = [] def __init__(self, **_kwargs: Any) -> None: pass def bucket_exists(self, bucket: str) -> bool: return self.bucket_exists_value def make_bucket(self, bucket: str, location: str | None = None) -> None: self.made_buckets.append(bucket) def list_objects(self, bucket: str, *, prefix: str, recursive: bool) -> list[_FakeObject]: return [_FakeObject(name) for name in self.objects if name.startswith(prefix)] def remove_objects(self, bucket: str, objects: list[Any]) -> list[Any]: self.removed_objects.extend(item.object_name for item in objects) return [] class _FakeAdmin: calls: list[tuple[str, Any]] = [] missing = False attach_policy_already_applied = False def __init__(self, **_kwargs: Any) -> None: pass def user_add(self, access_key: str, secret_key: str) -> None: self.calls.append(("user_add", access_key)) def policy_add(self, policy_name: str, *, policy: dict[str, Any]) -> None: self.calls.append(("policy_add", policy_name)) def attach_policy(self, **kwargs: Any) -> None: self.calls.append(("attach_policy", kwargs)) if self.attach_policy_already_applied: raise RuntimeError( "admin request failed; Status: 400, Body: " '{"Code":"XMinioAdminPolicyChangeAlreadyApplied",' '"Message":"The specified policy change is already in effect."}' ) def detach_policy(self, **kwargs: Any) -> None: self.calls.append(("detach_policy", kwargs)) if self.missing: raise RuntimeError("policy not found") def user_remove(self, access_key: str) -> None: self.calls.append(("user_remove", access_key)) if self.missing: raise RuntimeError("user not found") def policy_remove(self, policy_name: str) -> None: self.calls.append(("policy_remove", policy_name)) if self.missing: raise RuntimeError("policy not found") class _FakeStaticProvider: def __init__(self, **_kwargs: Any) -> None: pass class _FakeDeleteObject: def __init__(self, object_name: str) -> None: self.object_name = object_name def _install_fake_minio(monkeypatch) -> None: minio_module = ModuleType("minio") minio_module.Minio = _FakeMinio minio_module.MinioAdmin = _FakeAdmin credentials_module = ModuleType("minio.credentials") credentials_module.StaticProvider = _FakeStaticProvider deleteobjects_module = ModuleType("minio.deleteobjects") deleteobjects_module.DeleteObject = _FakeDeleteObject monkeypatch.setitem(sys.modules, "minio", minio_module) monkeypatch.setitem(sys.modules, "minio.credentials", credentials_module) monkeypatch.setitem(sys.modules, "minio.deleteobjects", deleteobjects_module) _FakeMinio.bucket_exists_value = True _FakeMinio.objects = [] _FakeMinio.removed_objects = [] _FakeMinio.made_buckets = [] _FakeAdmin.calls = [] _FakeAdmin.missing = False _FakeAdmin.attach_policy_already_applied = False def _config() -> MinIOProvisioningConfig: return MinIOProvisioningConfig( enabled=True, endpoint="minio.local:9000", public_endpoint="minio.local:9000", admin_access_key="admin", admin_secret_key="admin-secret", bucket="beaver-user-files", secure=False, region=None, ) def _settings() -> MinIOSettings: return MinIOSettings( endpoint="minio.local:9000", access_key="beaver-alice", secret_key="alice-secret", bucket="beaver-user-files", namespace="users/alice", ) def _client(tmp_path, monkeypatch) -> TestClient: monkeypatch.setenv("AUTHZ_DATA_DIR", str(tmp_path)) monkeypatch.setenv("AUTHZ_PRIVATE_KEY_PATH", str(tmp_path / "signing_key.pem")) monkeypatch.setenv("AUTHZ_INTERNAL_TOKEN", "test-internal-token") monkeypatch.setenv("USER_FILES_MINIO_ENDPOINT", "minio.local:9000") monkeypatch.setenv("USER_FILES_MINIO_ADMIN_ACCESS_KEY", "admin") monkeypatch.setenv("USER_FILES_MINIO_ADMIN_SECRET_KEY", "admin-secret") monkeypatch.setenv("USER_FILES_MINIO_BUCKET", "beaver-user-files") import app.main as main main = importlib.reload(main) return TestClient(main.app) def _register_backend(client: TestClient) -> None: response = client.post( "/backends/register", json={"backend_id": "alice", "name": "Alice", "base_url": "http://alice.local"}, ) assert response.status_code == 200 def test_deprovision_removes_namespace_resources_without_secrets(monkeypatch) -> None: _install_fake_minio(monkeypatch) _FakeMinio.objects = [ "users/alice/uploads/a.txt", "users/alice/outputs/b.txt", "users/bob/uploads/c.txt", ] result = deprovision_user_file_minio_resources( backend_id="alice", existing=_settings(), config=_config(), ) assert result["ok"] is True assert result["objects"] == {"status": "removed", "deleted": 2} assert _FakeMinio.removed_objects == ["users/alice/uploads/a.txt", "users/alice/outputs/b.txt"] assert ("user_remove", "beaver-alice") in _FakeAdmin.calls assert ("policy_remove", "beaver-user-files-alice") in _FakeAdmin.calls assert "secret" not in str(result).lower() def test_provision_treats_already_attached_policy_as_idempotent(monkeypatch) -> None: _install_fake_minio(monkeypatch) _FakeAdmin.attach_policy_already_applied = True settings = provision_user_file_minio_settings( backend_id="alice", existing=None, config=_config(), ) assert settings is not None assert settings.endpoint == "minio.local:9000" assert settings.access_key == "beaver-alice" assert settings.bucket == "beaver-user-files" assert settings.namespace == "users/alice" assert settings.secret_key assert ("attach_policy", {"policies": ["beaver-user-files-alice"], "user": "beaver-alice"}) in _FakeAdmin.calls def test_deprovision_is_idempotent_when_resources_are_absent(monkeypatch) -> None: _install_fake_minio(monkeypatch) _FakeMinio.bucket_exists_value = False _FakeAdmin.missing = True result = deprovision_user_file_minio_resources( backend_id="alice", existing=_settings(), config=_config(), ) assert result["ok"] is True assert result["bucket"] == "absent" assert result["objects"] == {"status": "absent", "deleted": 0} assert result["user"] == {"status": "absent"} assert result["policy"] == {"status": "absent"} def test_internal_user_file_deprovision_requires_internal_token(tmp_path, monkeypatch) -> None: with _client(tmp_path, monkeypatch) as client: unauthorized = client.delete("/internal/backends/alice/user-files") assert unauthorized.status_code == 401 def test_internal_user_file_deprovision_deletes_settings_without_returning_secret(tmp_path, monkeypatch) -> None: _install_fake_minio(monkeypatch) with _client(tmp_path, monkeypatch) as client: _register_backend(client) client.post( "/backends/alice/settings/minio", json={ "endpoint": "minio.local:9000", "access_key": "beaver-alice", "secret_key": "alice-secret", "bucket": "beaver-user-files", "namespace": "users/alice", }, ) deleted = client.delete( "/internal/backends/alice/user-files", headers={"Authorization": "Bearer test-internal-token"}, ) after_delete = client.get("/backends/alice/settings/minio") assert deleted.status_code == 200 payload = deleted.json() assert payload["ok"] is True assert payload["settings"] == {"status": "removed"} assert "secret" not in str(payload).lower() assert after_delete.json() == {"configured": False}