Skip to content

Commit 0ecb595

Browse files
jopemachineclaude
andauthored
test(BA-4988): add component tests for vfolder sharing and quota (#9890)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2573ff8 commit 0ecb595

4 files changed

Lines changed: 685 additions & 30 deletions

File tree

changes/9890.test.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add component tests for vfolder sharing and quota

tests/component/vfolder/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from ai.backend.manager.api.rest.types import RouteDeps
3232
from ai.backend.manager.api.rest.vfolder.handler import VFolderHandler
3333
from ai.backend.manager.api.rest.vfolder.registry import register_vfolder_routes
34+
from ai.backend.manager.clients.storage_proxy.session_manager import StorageSessionManager
3435
from ai.backend.manager.config.provider import ManagerConfigProvider
3536
from ai.backend.manager.data.vfolder.types import (
3637
VFolderInvitationState,
@@ -41,7 +42,6 @@
4142
from ai.backend.manager.dependencies.infrastructure.redis import ValkeyClients
4243
from ai.backend.manager.models.domain import domains
4344
from ai.backend.manager.models.resource_policy import keypair_resource_policies
44-
from ai.backend.manager.models.storage import StorageSessionManager
4545
from ai.backend.manager.models.utils import ExtendedAsyncSAEngine
4646
from ai.backend.manager.models.vfolder import (
4747
vfolder_invitations,
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable, Coroutine
4+
from typing import Any
5+
from unittest.mock import AsyncMock, MagicMock
6+
7+
import pytest
8+
9+
from ai.backend.client.exceptions import BackendAPIError
10+
from ai.backend.client.v2.registry import BackendAIClientRegistry
11+
from ai.backend.common.dto.manager.vfolder import (
12+
GetQuotaQuery,
13+
GetQuotaResponse,
14+
GetUsageQuery,
15+
GetUsageResponse,
16+
GetUsedBytesQuery,
17+
GetUsedBytesResponse,
18+
UpdateQuotaReq,
19+
UpdateQuotaResponse,
20+
)
21+
from ai.backend.common.types import QuotaScopeID, QuotaScopeType
22+
from ai.backend.manager.clients.storage_proxy.session_manager import StorageSessionManager
23+
24+
VFolderFixtureData = dict[str, Any]
25+
VFolderFactory = Callable[..., Coroutine[Any, Any, VFolderFixtureData]]
26+
27+
28+
@pytest.fixture()
29+
def storage_manager() -> StorageSessionManager:
30+
"""Mock StorageSessionManager with configured storage proxy client methods.
31+
32+
Overrides the parent conftest mock so that quota and usage endpoints work
33+
without a live storage-proxy connection.
34+
"""
35+
mock = MagicMock(spec=StorageSessionManager)
36+
mock_client = AsyncMock()
37+
mock_client.get_volume_quota.return_value = {"used_bytes": 0, "limit_bytes": 0}
38+
mock_client.update_volume_quota.return_value = None
39+
mock_client.get_folder_usage.return_value = {"used_bytes": 0, "file_count": 0}
40+
mock_client.get_used_bytes.return_value = {"used_bytes": 0}
41+
mock.get_proxy_and_volume.return_value = ("local", "volume")
42+
mock.get_manager_facing_client.return_value = mock_client
43+
return mock
44+
45+
46+
class TestStorageQuotaScope:
47+
"""Storage quota CRUD and access control via the quota scope API.
48+
Storage-proxy calls are mocked via the storage_manager fixture in conftest."""
49+
50+
async def test_get_quota(
51+
self,
52+
admin_registry: BackendAIClientRegistry,
53+
target_vfolder: VFolderFixtureData,
54+
) -> None:
55+
"""Scenario: Admin queries the quota for an existing USER vfolder on the
56+
'local' storage host. Verifies the response is a GetQuotaResponse with
57+
a data dict containing quota limit information."""
58+
result = await admin_registry.vfolder.get_quota(
59+
GetQuotaQuery(
60+
folder_host="local",
61+
id=target_vfolder["id"],
62+
),
63+
)
64+
assert isinstance(result, GetQuotaResponse)
65+
assert isinstance(result.data, dict)
66+
67+
async def test_update_quota(
68+
self,
69+
admin_registry: BackendAIClientRegistry,
70+
target_vfolder: VFolderFixtureData,
71+
) -> None:
72+
"""Scenario: Admin updates the quota for an existing vfolder to 100 MiB.
73+
Verifies the response is an UpdateQuotaResponse, confirming the
74+
storage-proxy accepted the new quota limit."""
75+
result = await admin_registry.vfolder.update_quota(
76+
UpdateQuotaReq(
77+
folder_host="local",
78+
id=target_vfolder["id"],
79+
input={"size_bytes": 1024 * 1024 * 100},
80+
),
81+
)
82+
assert isinstance(result, UpdateQuotaResponse)
83+
84+
async def test_get_usage(
85+
self,
86+
admin_registry: BackendAIClientRegistry,
87+
target_vfolder: VFolderFixtureData,
88+
) -> None:
89+
"""Scenario: Admin queries folder usage (file_count, used_bytes) for an
90+
existing vfolder. Verifies the response is a GetUsageResponse with a
91+
data dict containing usage statistics from the storage-proxy."""
92+
result = await admin_registry.vfolder.get_usage(
93+
GetUsageQuery(
94+
folder_host="local",
95+
id=target_vfolder["id"],
96+
),
97+
)
98+
assert isinstance(result, GetUsageResponse)
99+
assert isinstance(result.data, dict)
100+
101+
async def test_get_used_bytes(
102+
self,
103+
admin_registry: BackendAIClientRegistry,
104+
target_vfolder: VFolderFixtureData,
105+
) -> None:
106+
"""Scenario: Admin queries the used_bytes metric for an existing vfolder.
107+
This is a lightweight alternative to get_usage that returns only the byte
108+
count. Verifies the response is a GetUsedBytesResponse with a data dict."""
109+
result = await admin_registry.vfolder.get_used_bytes(
110+
GetUsedBytesQuery(
111+
folder_host="local",
112+
id=target_vfolder["id"],
113+
),
114+
)
115+
assert isinstance(result, GetUsedBytesResponse)
116+
assert isinstance(result.data, dict)
117+
118+
async def test_regular_user_can_get_own_quota(
119+
self,
120+
user_registry: BackendAIClientRegistry,
121+
vfolder_factory: VFolderFactory,
122+
regular_user_fixture: Any,
123+
) -> None:
124+
"""Scenario: A regular (non-admin) user creates a vfolder under their own
125+
quota scope (QuotaScopeType.USER) and queries its quota. Verifies that
126+
non-admin users are allowed to read quota info for their own vfolders."""
127+
user_uuid = regular_user_fixture.user_uuid
128+
vf = await vfolder_factory(
129+
user=str(user_uuid),
130+
creator="user-test@test.local",
131+
quota_scope_id=str(QuotaScopeID(scope_type=QuotaScopeType.USER, scope_id=user_uuid)),
132+
)
133+
result = await user_registry.vfolder.get_quota(
134+
GetQuotaQuery(folder_host="local", id=vf["id"]),
135+
)
136+
assert isinstance(result, GetQuotaResponse)
137+
138+
async def test_regular_user_cannot_update_others_quota(
139+
self,
140+
user_registry: BackendAIClientRegistry,
141+
target_vfolder: VFolderFixtureData,
142+
) -> None:
143+
"""Scenario: A regular user attempts to update the quota of a vfolder owned
144+
by the admin. The server should reject this with BackendAPIError because
145+
quota modification requires admin privileges or ownership of the vfolder."""
146+
with pytest.raises(BackendAPIError):
147+
await user_registry.vfolder.update_quota(
148+
UpdateQuotaReq(
149+
folder_host="local",
150+
id=target_vfolder["id"],
151+
input={"size_bytes": 999999},
152+
),
153+
)

0 commit comments

Comments
 (0)