Skip to content

Commit bf016ab

Browse files
committed
Merge branch 'main' of https://github.com/transformerlab/transformerlab-app into fix/local-model-vllm
2 parents a529782 + d641fb7 commit bf016ab

4 files changed

Lines changed: 1075 additions & 1110 deletions

File tree

api/test/test_team_service.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import io
2+
import pytest
3+
import types
4+
from datetime import datetime, timedelta
5+
from unittest.mock import AsyncMock, MagicMock
6+
from uuid import UUID
7+
8+
from fastapi import HTTPException
9+
from PIL import Image
10+
11+
from transformerlab.shared.models.models import InvitationStatus
12+
13+
14+
# ==================== Helpers ====================
15+
16+
17+
def _make_png_bytes(mode: str = "RGB") -> bytes:
18+
buf = io.BytesIO()
19+
color = (255, 0, 0) if mode == "RGB" else (255, 0, 0, 128)
20+
Image.new(mode, (10, 10), color=color).save(buf, format="PNG")
21+
return buf.getvalue()
22+
23+
24+
def _make_mock_session():
25+
session = MagicMock()
26+
session.add = MagicMock()
27+
session.commit = AsyncMock()
28+
session.refresh = AsyncMock()
29+
session.execute = AsyncMock()
30+
session.delete = AsyncMock()
31+
return session
32+
33+
34+
# ==================== Logo validation ====================
35+
36+
37+
def test_normalize_uuid_ids_filters_invalid_values():
38+
from transformerlab.services.team_service import _normalize_uuid_ids
39+
40+
valid_1 = "12345678-1234-1234-1234-1234567890ab"
41+
valid_2 = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
42+
43+
result = _normalize_uuid_ids([valid_1, "not-a-uuid", None, valid_2]) # type: ignore[list-item]
44+
45+
assert result == [UUID(valid_1), UUID(valid_2)]
46+
47+
48+
def test_validate_logo_rejects_non_image_content_type():
49+
from transformerlab.services.team_service import _validate_and_process_logo
50+
51+
with pytest.raises(HTTPException) as exc:
52+
_validate_and_process_logo(b"data", content_type="application/pdf", filename="file.pdf")
53+
assert exc.value.status_code == 400
54+
assert "Invalid file type" in exc.value.detail
55+
56+
57+
def test_validate_logo_rejects_bad_extension():
58+
from transformerlab.services.team_service import _validate_and_process_logo
59+
60+
with pytest.raises(HTTPException) as exc:
61+
_validate_and_process_logo(b"data", content_type="image/png", filename="file.exe")
62+
assert exc.value.status_code == 400
63+
assert "Invalid file extension" in exc.value.detail
64+
65+
66+
def test_validate_logo_rejects_oversized_file():
67+
from transformerlab.services.team_service import _validate_and_process_logo
68+
69+
big = b"x" * (1 * 1024 * 1024 + 1)
70+
with pytest.raises(HTTPException) as exc:
71+
_validate_and_process_logo(big, content_type="image/png", filename="logo.png")
72+
assert exc.value.status_code == 400
73+
assert "exceeds maximum" in exc.value.detail
74+
75+
76+
def test_validate_logo_rejects_invalid_image_data():
77+
from transformerlab.services.team_service import _validate_and_process_logo
78+
79+
with pytest.raises(HTTPException) as exc:
80+
_validate_and_process_logo(b"not-an-image", content_type="image/png", filename="logo.png")
81+
assert exc.value.status_code == 400
82+
assert "Invalid image file" in exc.value.detail
83+
84+
85+
def test_validate_logo_converts_rgba_to_rgb():
86+
from transformerlab.services.team_service import _validate_and_process_logo
87+
88+
result = _validate_and_process_logo(_make_png_bytes("RGBA"), content_type="image/png", filename="logo.png")
89+
assert result.mode == "RGB"
90+
91+
92+
def test_validate_logo_accepts_valid_rgb_png():
93+
from transformerlab.services.team_service import _validate_and_process_logo
94+
95+
result = _validate_and_process_logo(_make_png_bytes("RGB"), content_type="image/png", filename="logo.png")
96+
assert result.mode == "RGB"
97+
98+
99+
async def test_set_team_logo_writes_png_bytes_to_async_storage(monkeypatch):
100+
from transformerlab.services import team_service
101+
102+
write_mock = AsyncMock()
103+
104+
class _AsyncWriter:
105+
async def __aenter__(self):
106+
return types.SimpleNamespace(write=write_mock)
107+
108+
async def __aexit__(self, _exc_type, _exc, _tb):
109+
return False
110+
111+
storage_mock = types.SimpleNamespace(
112+
join=lambda base, name: f"{base}/{name}",
113+
open=AsyncMock(return_value=_AsyncWriter()),
114+
)
115+
116+
monkeypatch.setattr(team_service, "storage", storage_mock)
117+
118+
result = await team_service.set_team_logo("/tmp/workspace", _make_png_bytes("RGBA"), "image/png", "logo.png")
119+
120+
assert result["status"] == "success"
121+
write_mock.assert_awaited_once()
122+
written_bytes = write_mock.await_args.args[0]
123+
assert isinstance(written_bytes, bytes)
124+
assert written_bytes.startswith(b"\x89PNG\r\n\x1a\n")
125+
126+
127+
# ==================== Personal team detection ====================
128+
129+
130+
def test_is_personal_team_true():
131+
from transformerlab.services.team_service import _is_personal_team
132+
133+
user = MagicMock()
134+
user.first_name = "Alice"
135+
user.email = "alice@example.com"
136+
team = MagicMock()
137+
team.name = "Alice's Team"
138+
assert _is_personal_team(user, team) is True
139+
140+
141+
def test_is_personal_team_false():
142+
from transformerlab.services.team_service import _is_personal_team
143+
144+
user = MagicMock()
145+
user.first_name = "Alice"
146+
user.email = "alice@example.com"
147+
team = MagicMock()
148+
team.name = "Research Group"
149+
assert _is_personal_team(user, team) is False
150+
151+
152+
def test_is_personal_team_falls_back_to_email_prefix():
153+
from transformerlab.services.team_service import _is_personal_team
154+
155+
user = MagicMock()
156+
user.first_name = None
157+
user.email = "bob@example.com"
158+
team = MagicMock()
159+
team.name = "bob's Team"
160+
assert _is_personal_team(user, team) is True
161+
162+
163+
# ==================== Member management ====================
164+
165+
166+
async def test_remove_member_raises_if_not_in_team():
167+
from transformerlab.services.team_service import remove_member
168+
169+
session = _make_mock_session()
170+
mock_result = MagicMock()
171+
mock_result.scalar_one_or_none.return_value = None
172+
session.execute.return_value = mock_result
173+
174+
with pytest.raises(HTTPException) as exc:
175+
await remove_member(session, "team-1", "user-99")
176+
assert exc.value.status_code == 404
177+
178+
179+
async def test_update_member_role_raises_on_invalid_role():
180+
from transformerlab.services.team_service import update_member_role
181+
182+
session = _make_mock_session()
183+
with pytest.raises(HTTPException) as exc:
184+
await update_member_role(session, "team-1", "user-1", "superadmin")
185+
assert exc.value.status_code == 400
186+
assert "Invalid role" in exc.value.detail
187+
188+
189+
# ==================== Invitation logic ====================
190+
191+
192+
async def test_accept_invitation_raises_if_not_found():
193+
from transformerlab.services.team_service import accept_invitation
194+
195+
session = _make_mock_session()
196+
mock_result = MagicMock()
197+
mock_result.scalar_one_or_none.return_value = None
198+
session.execute.return_value = mock_result
199+
200+
user = MagicMock()
201+
user.email = "alice@example.com"
202+
user.id = "user-1"
203+
204+
with pytest.raises(HTTPException) as exc:
205+
await accept_invitation(session, user, token="bad-token")
206+
assert exc.value.status_code == 404
207+
208+
209+
async def test_accept_invitation_raises_if_wrong_email():
210+
from transformerlab.services.team_service import accept_invitation
211+
212+
session = _make_mock_session()
213+
214+
invitation = MagicMock()
215+
invitation.email = "other@example.com"
216+
invitation.status = InvitationStatus.PENDING.value
217+
invitation.expires_at = datetime.utcnow() + timedelta(days=1)
218+
219+
mock_result = MagicMock()
220+
mock_result.scalar_one_or_none.return_value = invitation
221+
session.execute.return_value = mock_result
222+
223+
user = MagicMock()
224+
user.email = "alice@example.com"
225+
user.id = "user-1"
226+
227+
with pytest.raises(HTTPException) as exc:
228+
await accept_invitation(session, user, token="some-token")
229+
assert exc.value.status_code == 403
230+
231+
232+
async def test_accept_invitation_by_id_raises_if_not_found():
233+
from transformerlab.services.team_service import accept_invitation
234+
235+
session = _make_mock_session()
236+
mock_result = MagicMock()
237+
mock_result.scalar_one_or_none.return_value = None
238+
session.execute.return_value = mock_result
239+
240+
user = MagicMock()
241+
user.email = "alice@example.com"
242+
user.id = "user-1"
243+
244+
with pytest.raises(HTTPException) as exc:
245+
await accept_invitation(session, user, invitation_id="inv-bad")
246+
assert exc.value.status_code == 404
247+
248+
249+
async def test_reject_invitation_raises_if_not_pending():
250+
from transformerlab.services.team_service import reject_invitation
251+
252+
session = _make_mock_session()
253+
254+
invitation = MagicMock()
255+
invitation.email = "alice@example.com"
256+
invitation.status = InvitationStatus.ACCEPTED.value
257+
258+
mock_result = MagicMock()
259+
mock_result.scalar_one_or_none.return_value = invitation
260+
session.execute.return_value = mock_result
261+
262+
with pytest.raises(HTTPException) as exc:
263+
await reject_invitation(session, "inv-1", "alice@example.com")
264+
assert exc.value.status_code == 400

api/transformerlab/routers/auth.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -549,14 +549,15 @@ async def get_user_teams(user: User = Depends(current_active_user), session: Asy
549549

550550
@router.get("/users/me/secrets")
551551
async def get_user_secrets(
552-
user: User = Depends(current_active_user),
552+
user_and_team=Depends(get_user_and_team),
553553
include_values: bool = Query(False, description="Include actual secret values"),
554554
):
555555
"""
556556
Get user-specific secrets.
557557
- Users can view their own secret keys and values.
558558
- Values are always included since users own their secrets.
559559
"""
560+
user = user_and_team["user"]
560561
user_id = str(user.id)
561562
workspace_dir = await get_workspace_dir()
562563
secrets_path = storage.join(workspace_dir, f"user_secrets_{user_id}.json")
@@ -591,12 +592,13 @@ async def get_user_secrets(
591592
@router.put("/users/me/secrets")
592593
async def set_user_secrets(
593594
secrets_data: UserSecretsRequest,
594-
user: User = Depends(current_active_user),
595+
user_and_team=Depends(get_user_and_team),
595596
):
596597
"""
597598
Set user-specific secrets. Users can set/update their own secrets.
598599
Stored in workspace/user_secrets_{user_id}.json file.
599600
"""
601+
user = user_and_team["user"]
600602
user_id = str(user.id)
601603
workspace_dir = await get_workspace_dir()
602604
secrets_path = storage.join(workspace_dir, f"user_secrets_{user_id}.json")
@@ -639,12 +641,13 @@ async def set_user_secrets(
639641

640642
@router.get("/users/me/special_secrets")
641643
async def get_user_special_secrets(
642-
user: User = Depends(current_active_user),
644+
user_and_team=Depends(get_user_and_team),
643645
):
644646
"""
645647
Get user special secrets.
646648
Users can view which special secrets are configured (values are masked).
647649
"""
650+
user = user_and_team["user"]
648651
user_id = str(user.id)
649652
workspace_dir = await get_workspace_dir()
650653
secrets_path = storage.join(workspace_dir, f"user_secrets_{user_id}.json")
@@ -686,7 +689,7 @@ async def get_user_special_secrets(
686689
@router.put("/users/me/special_secrets")
687690
async def set_user_special_secret(
688691
secret_data: SpecialSecretRequest,
689-
user: User = Depends(current_active_user),
692+
user_and_team=Depends(get_user_and_team),
690693
):
691694
"""
692695
Set a user special secret. Users can set/update their own special secrets.
@@ -699,6 +702,7 @@ async def set_user_special_secret(
699702
detail=f"Invalid secret type '{secret_data.secret_type}'. Must be one of: {', '.join(SPECIAL_SECRET_TYPES.keys())}",
700703
)
701704

705+
user = user_and_team["user"]
702706
user_id = str(user.id)
703707
workspace_dir = await get_workspace_dir()
704708
secrets_path = storage.join(workspace_dir, f"user_secrets_{user_id}.json")

0 commit comments

Comments
 (0)