karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/api/routes/admin.py +512 -1
- backend/api/routes/audio_search.py +17 -34
- backend/api/routes/file_upload.py +60 -84
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +11 -3
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +229 -247
- backend/config.py +16 -0
- backend/exceptions.py +66 -0
- backend/main.py +30 -1
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +27 -0
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +73 -3
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/conftest.py +7 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_audio_search.py +12 -8
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +146 -1
- backend/tests/test_made_for_you.py +2088 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/workers/video_worker.py +8 -3
- backend/workers/video_worker_orchestrator.py +26 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for tenant API routes.
|
|
3
|
+
|
|
4
|
+
Tests the /api/tenant/config endpoints.
|
|
5
|
+
|
|
6
|
+
Note: We need to patch get_tenant_service in both the routes module AND the
|
|
7
|
+
middleware module since the middleware runs first and also calls get_tenant_service.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from unittest.mock import MagicMock, patch
|
|
12
|
+
from fastapi.testclient import TestClient
|
|
13
|
+
|
|
14
|
+
from backend.models.tenant import (
|
|
15
|
+
TenantConfig,
|
|
16
|
+
TenantBranding,
|
|
17
|
+
TenantFeatures,
|
|
18
|
+
TenantDefaults,
|
|
19
|
+
TenantAuth,
|
|
20
|
+
TenantPublicConfig,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Sample tenant config for mocking
|
|
25
|
+
SAMPLE_VOCALSTAR_CONFIG = TenantConfig(
|
|
26
|
+
id="vocalstar",
|
|
27
|
+
name="Vocal Star",
|
|
28
|
+
subdomain="vocalstar.nomadkaraoke.com",
|
|
29
|
+
is_active=True,
|
|
30
|
+
branding=TenantBranding(
|
|
31
|
+
logo_url="https://example.com/logo.png",
|
|
32
|
+
primary_color="#ffff00",
|
|
33
|
+
secondary_color="#006CF9",
|
|
34
|
+
site_title="Vocal Star Karaoke Generator",
|
|
35
|
+
),
|
|
36
|
+
features=TenantFeatures(
|
|
37
|
+
audio_search=False,
|
|
38
|
+
youtube_url=False,
|
|
39
|
+
youtube_upload=False,
|
|
40
|
+
dropbox_upload=False,
|
|
41
|
+
gdrive_upload=False,
|
|
42
|
+
theme_selection=False,
|
|
43
|
+
),
|
|
44
|
+
defaults=TenantDefaults(
|
|
45
|
+
theme_id="vocalstar",
|
|
46
|
+
locked_theme="vocalstar",
|
|
47
|
+
distribution_mode="download_only",
|
|
48
|
+
),
|
|
49
|
+
auth=TenantAuth(
|
|
50
|
+
allowed_email_domains=["vocal-star.com", "vocalstarmusic.com"],
|
|
51
|
+
require_email_domain=True,
|
|
52
|
+
sender_email="vocalstar@nomadkaraoke.com",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
INACTIVE_CONFIG = TenantConfig(
|
|
57
|
+
id="inactive",
|
|
58
|
+
name="Inactive Tenant",
|
|
59
|
+
subdomain="inactive.nomadkaraoke.com",
|
|
60
|
+
is_active=False,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestTenantConfigEndpoint:
|
|
65
|
+
"""Tests for GET /api/tenant/config endpoint."""
|
|
66
|
+
|
|
67
|
+
@pytest.fixture
|
|
68
|
+
def mock_tenant_service(self):
|
|
69
|
+
"""Mock the tenant service in both routes and middleware."""
|
|
70
|
+
service = MagicMock()
|
|
71
|
+
# Patch in both places: routes AND middleware
|
|
72
|
+
with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
|
|
73
|
+
patch("backend.middleware.tenant.get_tenant_service", return_value=service):
|
|
74
|
+
yield service
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def client(self, mock_tenant_service):
|
|
78
|
+
"""Create test client with mocked tenant service."""
|
|
79
|
+
from backend.main import app
|
|
80
|
+
return TestClient(app)
|
|
81
|
+
|
|
82
|
+
def test_get_config_default(self, client, mock_tenant_service):
|
|
83
|
+
"""Test returns default config when no tenant detected."""
|
|
84
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
85
|
+
|
|
86
|
+
response = client.get("/api/tenant/config")
|
|
87
|
+
|
|
88
|
+
assert response.status_code == 200
|
|
89
|
+
data = response.json()
|
|
90
|
+
assert data["tenant"] is None
|
|
91
|
+
assert data["is_default"] is True
|
|
92
|
+
|
|
93
|
+
def test_get_config_with_query_param(self, client, mock_tenant_service):
|
|
94
|
+
"""Test tenant detection via query parameter."""
|
|
95
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
96
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
97
|
+
|
|
98
|
+
response = client.get("/api/tenant/config?tenant=vocalstar")
|
|
99
|
+
|
|
100
|
+
assert response.status_code == 200
|
|
101
|
+
data = response.json()
|
|
102
|
+
assert data["tenant"]["id"] == "vocalstar"
|
|
103
|
+
assert data["tenant"]["name"] == "Vocal Star"
|
|
104
|
+
assert data["is_default"] is False
|
|
105
|
+
|
|
106
|
+
def test_get_config_with_header(self, client, mock_tenant_service):
|
|
107
|
+
"""Test tenant detection via X-Tenant-ID header."""
|
|
108
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
109
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
110
|
+
|
|
111
|
+
response = client.get(
|
|
112
|
+
"/api/tenant/config",
|
|
113
|
+
headers={"X-Tenant-ID": "vocalstar"},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert response.status_code == 200
|
|
117
|
+
data = response.json()
|
|
118
|
+
assert data["tenant"]["id"] == "vocalstar"
|
|
119
|
+
assert data["is_default"] is False
|
|
120
|
+
|
|
121
|
+
def test_get_config_query_param_takes_priority(self, client, mock_tenant_service):
|
|
122
|
+
"""Test query param takes priority over header."""
|
|
123
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
124
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
125
|
+
|
|
126
|
+
response = client.get(
|
|
127
|
+
"/api/tenant/config?tenant=vocalstar",
|
|
128
|
+
headers={"X-Tenant-ID": "other"},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
assert response.status_code == 200
|
|
132
|
+
# Should use vocalstar from query param
|
|
133
|
+
mock_tenant_service.get_public_config.assert_called_with("vocalstar")
|
|
134
|
+
|
|
135
|
+
def test_get_config_tenant_not_found(self, client, mock_tenant_service):
|
|
136
|
+
"""Test returns default when tenant not found."""
|
|
137
|
+
mock_tenant_service.get_public_config.return_value = None
|
|
138
|
+
|
|
139
|
+
response = client.get("/api/tenant/config?tenant=nonexistent")
|
|
140
|
+
|
|
141
|
+
assert response.status_code == 200
|
|
142
|
+
data = response.json()
|
|
143
|
+
assert data["tenant"] is None
|
|
144
|
+
assert data["is_default"] is True
|
|
145
|
+
|
|
146
|
+
def test_get_config_inactive_tenant(self, client, mock_tenant_service):
|
|
147
|
+
"""Test returns default when tenant is inactive."""
|
|
148
|
+
inactive_public = TenantPublicConfig.from_config(INACTIVE_CONFIG)
|
|
149
|
+
mock_tenant_service.get_public_config.return_value = inactive_public
|
|
150
|
+
|
|
151
|
+
response = client.get("/api/tenant/config?tenant=inactive")
|
|
152
|
+
|
|
153
|
+
assert response.status_code == 200
|
|
154
|
+
data = response.json()
|
|
155
|
+
assert data["tenant"] is None
|
|
156
|
+
assert data["is_default"] is True
|
|
157
|
+
|
|
158
|
+
def test_get_config_includes_branding(self, client, mock_tenant_service):
|
|
159
|
+
"""Test response includes full branding config."""
|
|
160
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
161
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
162
|
+
|
|
163
|
+
response = client.get("/api/tenant/config?tenant=vocalstar")
|
|
164
|
+
|
|
165
|
+
assert response.status_code == 200
|
|
166
|
+
data = response.json()
|
|
167
|
+
branding = data["tenant"]["branding"]
|
|
168
|
+
assert branding["primary_color"] == "#ffff00"
|
|
169
|
+
assert branding["secondary_color"] == "#006CF9"
|
|
170
|
+
assert branding["site_title"] == "Vocal Star Karaoke Generator"
|
|
171
|
+
|
|
172
|
+
def test_get_config_includes_features(self, client, mock_tenant_service):
|
|
173
|
+
"""Test response includes feature flags."""
|
|
174
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
175
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
176
|
+
|
|
177
|
+
response = client.get("/api/tenant/config?tenant=vocalstar")
|
|
178
|
+
|
|
179
|
+
assert response.status_code == 200
|
|
180
|
+
data = response.json()
|
|
181
|
+
features = data["tenant"]["features"]
|
|
182
|
+
assert features["audio_search"] is False
|
|
183
|
+
assert features["file_upload"] is True
|
|
184
|
+
assert features["youtube_upload"] is False
|
|
185
|
+
|
|
186
|
+
def test_get_config_includes_defaults(self, client, mock_tenant_service):
|
|
187
|
+
"""Test response includes default settings."""
|
|
188
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
189
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
190
|
+
|
|
191
|
+
response = client.get("/api/tenant/config?tenant=vocalstar")
|
|
192
|
+
|
|
193
|
+
assert response.status_code == 200
|
|
194
|
+
data = response.json()
|
|
195
|
+
defaults = data["tenant"]["defaults"]
|
|
196
|
+
assert defaults["theme_id"] == "vocalstar"
|
|
197
|
+
assert defaults["locked_theme"] == "vocalstar"
|
|
198
|
+
assert defaults["distribution_mode"] == "download_only"
|
|
199
|
+
|
|
200
|
+
def test_get_config_includes_allowed_domains(self, client, mock_tenant_service):
|
|
201
|
+
"""Test response includes allowed email domains."""
|
|
202
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
203
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
204
|
+
|
|
205
|
+
response = client.get("/api/tenant/config?tenant=vocalstar")
|
|
206
|
+
|
|
207
|
+
assert response.status_code == 200
|
|
208
|
+
data = response.json()
|
|
209
|
+
assert "vocal-star.com" in data["tenant"]["allowed_email_domains"]
|
|
210
|
+
assert "vocalstarmusic.com" in data["tenant"]["allowed_email_domains"]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestTenantConfigByIdEndpoint:
|
|
214
|
+
"""Tests for GET /api/tenant/config/{tenant_id} endpoint."""
|
|
215
|
+
|
|
216
|
+
@pytest.fixture
|
|
217
|
+
def mock_tenant_service(self):
|
|
218
|
+
"""Mock the tenant service in both routes and middleware."""
|
|
219
|
+
service = MagicMock()
|
|
220
|
+
with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
|
|
221
|
+
patch("backend.middleware.tenant.get_tenant_service", return_value=service):
|
|
222
|
+
yield service
|
|
223
|
+
|
|
224
|
+
@pytest.fixture
|
|
225
|
+
def client(self, mock_tenant_service):
|
|
226
|
+
"""Create test client with mocked tenant service."""
|
|
227
|
+
from backend.main import app
|
|
228
|
+
return TestClient(app)
|
|
229
|
+
|
|
230
|
+
def test_get_config_by_id_success(self, client, mock_tenant_service):
|
|
231
|
+
"""Test getting config by explicit ID."""
|
|
232
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
233
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
234
|
+
|
|
235
|
+
response = client.get("/api/tenant/config/vocalstar")
|
|
236
|
+
|
|
237
|
+
assert response.status_code == 200
|
|
238
|
+
data = response.json()
|
|
239
|
+
assert data["tenant"]["id"] == "vocalstar"
|
|
240
|
+
assert data["is_default"] is False
|
|
241
|
+
|
|
242
|
+
def test_get_config_by_id_not_found(self, client, mock_tenant_service):
|
|
243
|
+
"""Test returns default when tenant ID not found."""
|
|
244
|
+
mock_tenant_service.get_public_config.return_value = None
|
|
245
|
+
|
|
246
|
+
response = client.get("/api/tenant/config/nonexistent")
|
|
247
|
+
|
|
248
|
+
assert response.status_code == 200
|
|
249
|
+
data = response.json()
|
|
250
|
+
assert data["tenant"] is None
|
|
251
|
+
assert data["is_default"] is True
|
|
252
|
+
|
|
253
|
+
def test_get_config_by_id_inactive(self, client, mock_tenant_service):
|
|
254
|
+
"""Test returns default when tenant is inactive."""
|
|
255
|
+
inactive_public = TenantPublicConfig.from_config(INACTIVE_CONFIG)
|
|
256
|
+
mock_tenant_service.get_public_config.return_value = inactive_public
|
|
257
|
+
|
|
258
|
+
response = client.get("/api/tenant/config/inactive")
|
|
259
|
+
|
|
260
|
+
assert response.status_code == 200
|
|
261
|
+
data = response.json()
|
|
262
|
+
assert data["tenant"] is None
|
|
263
|
+
assert data["is_default"] is True
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class TestTenantAssetEndpoint:
|
|
267
|
+
"""Tests for GET /api/tenant/asset/{tenant_id}/{asset_name} endpoint."""
|
|
268
|
+
|
|
269
|
+
@pytest.fixture
|
|
270
|
+
def mock_tenant_service(self):
|
|
271
|
+
"""Mock the tenant service in both routes and middleware."""
|
|
272
|
+
service = MagicMock()
|
|
273
|
+
with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
|
|
274
|
+
patch("backend.middleware.tenant.get_tenant_service", return_value=service):
|
|
275
|
+
yield service
|
|
276
|
+
|
|
277
|
+
@pytest.fixture
|
|
278
|
+
def client(self, mock_tenant_service):
|
|
279
|
+
"""Create test client with mocked tenant service."""
|
|
280
|
+
from backend.main import app
|
|
281
|
+
return TestClient(app)
|
|
282
|
+
|
|
283
|
+
def test_get_asset_redirects(self, client, mock_tenant_service):
|
|
284
|
+
"""Test asset endpoint redirects to signed URL."""
|
|
285
|
+
mock_tenant_service.get_asset_url.return_value = "https://storage.googleapis.com/signed-url"
|
|
286
|
+
|
|
287
|
+
response = client.get(
|
|
288
|
+
"/api/tenant/asset/vocalstar/logo.png",
|
|
289
|
+
follow_redirects=False,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Should redirect (307 Temporary Redirect is FastAPI default)
|
|
293
|
+
assert response.status_code == 307
|
|
294
|
+
assert "storage.googleapis.com" in response.headers["location"]
|
|
295
|
+
|
|
296
|
+
def test_get_asset_not_found(self, client, mock_tenant_service):
|
|
297
|
+
"""Test returns 404 when asset not found."""
|
|
298
|
+
mock_tenant_service.get_asset_url.return_value = None
|
|
299
|
+
|
|
300
|
+
response = client.get("/api/tenant/asset/vocalstar/missing.png")
|
|
301
|
+
|
|
302
|
+
assert response.status_code == 404
|
|
303
|
+
assert "not found" in response.json()["detail"].lower()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class TestTenantConfigResponseSchema:
|
|
307
|
+
"""Tests for response schema validation."""
|
|
308
|
+
|
|
309
|
+
@pytest.fixture
|
|
310
|
+
def mock_tenant_service(self):
|
|
311
|
+
"""Mock the tenant service in both routes and middleware."""
|
|
312
|
+
service = MagicMock()
|
|
313
|
+
with patch("backend.api.routes.tenant.get_tenant_service", return_value=service), \
|
|
314
|
+
patch("backend.middleware.tenant.get_tenant_service", return_value=service):
|
|
315
|
+
yield service
|
|
316
|
+
|
|
317
|
+
@pytest.fixture
|
|
318
|
+
def client(self, mock_tenant_service):
|
|
319
|
+
"""Create test client with mocked tenant service."""
|
|
320
|
+
from backend.main import app
|
|
321
|
+
return TestClient(app)
|
|
322
|
+
|
|
323
|
+
def test_response_has_required_fields(self, client, mock_tenant_service):
|
|
324
|
+
"""Test response always has required fields."""
|
|
325
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
326
|
+
|
|
327
|
+
response = client.get("/api/tenant/config")
|
|
328
|
+
|
|
329
|
+
assert response.status_code == 200
|
|
330
|
+
data = response.json()
|
|
331
|
+
assert "tenant" in data
|
|
332
|
+
assert "is_default" in data
|
|
333
|
+
|
|
334
|
+
def test_tenant_config_excludes_sensitive_fields(self, client, mock_tenant_service):
|
|
335
|
+
"""Test public config doesn't include sensitive auth data."""
|
|
336
|
+
public_config = TenantPublicConfig.from_config(SAMPLE_VOCALSTAR_CONFIG)
|
|
337
|
+
mock_tenant_service.get_public_config.return_value = public_config
|
|
338
|
+
|
|
339
|
+
response = client.get("/api/tenant/config?tenant=vocalstar")
|
|
340
|
+
|
|
341
|
+
assert response.status_code == 200
|
|
342
|
+
data = response.json()
|
|
343
|
+
tenant = data["tenant"]
|
|
344
|
+
|
|
345
|
+
# Should NOT have full auth object
|
|
346
|
+
assert "auth" not in tenant
|
|
347
|
+
# Should NOT have sensitive defaults
|
|
348
|
+
assert tenant["defaults"].get("brand_prefix") is None
|
|
349
|
+
# Should have allowed_email_domains at top level
|
|
350
|
+
assert "allowed_email_domains" in tenant
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for TenantMiddleware.
|
|
3
|
+
|
|
4
|
+
Tests tenant detection from headers, query params, and subdomains,
|
|
5
|
+
as well as request state attachment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from unittest.mock import MagicMock, patch, AsyncMock
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.datastructures import Headers
|
|
14
|
+
from starlette.responses import Response
|
|
15
|
+
|
|
16
|
+
from backend.middleware.tenant import (
|
|
17
|
+
TenantMiddleware,
|
|
18
|
+
get_tenant_from_request,
|
|
19
|
+
get_tenant_config_from_request,
|
|
20
|
+
NON_TENANT_SUBDOMAINS,
|
|
21
|
+
)
|
|
22
|
+
from backend.models.tenant import TenantConfig, TenantFeatures
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Sample tenant config for mocking
|
|
26
|
+
SAMPLE_CONFIG = TenantConfig(
|
|
27
|
+
id="vocalstar",
|
|
28
|
+
name="Vocal Star",
|
|
29
|
+
subdomain="vocalstar.nomadkaraoke.com",
|
|
30
|
+
is_active=True,
|
|
31
|
+
features=TenantFeatures(audio_search=False),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
INACTIVE_CONFIG = TenantConfig(
|
|
35
|
+
id="inactive",
|
|
36
|
+
name="Inactive",
|
|
37
|
+
subdomain="inactive.nomadkaraoke.com",
|
|
38
|
+
is_active=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MockRequest:
|
|
43
|
+
"""Mock Request object for testing."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, headers=None, query_params=None):
|
|
46
|
+
self.headers = headers or {}
|
|
47
|
+
self.query_params = query_params or {}
|
|
48
|
+
self.url = MagicMock()
|
|
49
|
+
self.url.path = "/api/test"
|
|
50
|
+
self.state = MagicMock()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestTenantMiddleware:
|
|
54
|
+
"""Tests for TenantMiddleware class."""
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def middleware(self):
|
|
58
|
+
"""Create middleware instance."""
|
|
59
|
+
app = MagicMock()
|
|
60
|
+
return TenantMiddleware(app)
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def mock_tenant_service(self):
|
|
64
|
+
"""Create mock tenant service."""
|
|
65
|
+
with patch("backend.middleware.tenant.get_tenant_service") as mock:
|
|
66
|
+
service = MagicMock()
|
|
67
|
+
mock.return_value = service
|
|
68
|
+
yield service
|
|
69
|
+
|
|
70
|
+
# Tests for _extract_tenant_from_host()
|
|
71
|
+
def test_extract_tenant_from_host_standard_subdomain(self, middleware, mock_tenant_service):
|
|
72
|
+
"""Test extracting tenant from vocalstar.nomadkaraoke.com."""
|
|
73
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
74
|
+
|
|
75
|
+
tenant_id = middleware._extract_tenant_from_host("vocalstar.nomadkaraoke.com")
|
|
76
|
+
|
|
77
|
+
assert tenant_id == "vocalstar"
|
|
78
|
+
|
|
79
|
+
def test_extract_tenant_from_host_gen_subdomain(self, middleware, mock_tenant_service):
|
|
80
|
+
"""Test extracting tenant from vocalstar.gen.nomadkaraoke.com."""
|
|
81
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
82
|
+
|
|
83
|
+
tenant_id = middleware._extract_tenant_from_host("vocalstar.gen.nomadkaraoke.com")
|
|
84
|
+
|
|
85
|
+
assert tenant_id == "vocalstar"
|
|
86
|
+
|
|
87
|
+
def test_extract_tenant_from_host_with_port(self, middleware, mock_tenant_service):
|
|
88
|
+
"""Test extracting tenant when host includes port."""
|
|
89
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
90
|
+
|
|
91
|
+
tenant_id = middleware._extract_tenant_from_host("vocalstar.nomadkaraoke.com:443")
|
|
92
|
+
|
|
93
|
+
assert tenant_id == "vocalstar"
|
|
94
|
+
|
|
95
|
+
def test_extract_tenant_from_host_case_insensitive(self, middleware, mock_tenant_service):
|
|
96
|
+
"""Test host parsing is case insensitive."""
|
|
97
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
98
|
+
|
|
99
|
+
tenant_id = middleware._extract_tenant_from_host("VOCALSTAR.NomadKaraoke.COM")
|
|
100
|
+
|
|
101
|
+
assert tenant_id == "vocalstar"
|
|
102
|
+
|
|
103
|
+
def test_extract_tenant_from_host_empty(self, middleware):
|
|
104
|
+
"""Test returns None for empty host."""
|
|
105
|
+
tenant_id = middleware._extract_tenant_from_host("")
|
|
106
|
+
|
|
107
|
+
assert tenant_id is None
|
|
108
|
+
|
|
109
|
+
def test_extract_tenant_from_host_localhost(self, middleware):
|
|
110
|
+
"""Test returns None for localhost."""
|
|
111
|
+
tenant_id = middleware._extract_tenant_from_host("localhost:3000")
|
|
112
|
+
|
|
113
|
+
assert tenant_id is None
|
|
114
|
+
|
|
115
|
+
def test_extract_tenant_from_host_non_nomad_domain(self, middleware):
|
|
116
|
+
"""Test returns None for non-nomadkaraoke.com domains."""
|
|
117
|
+
tenant_id = middleware._extract_tenant_from_host("example.com")
|
|
118
|
+
|
|
119
|
+
assert tenant_id is None
|
|
120
|
+
|
|
121
|
+
def test_extract_tenant_from_host_base_domain(self, middleware):
|
|
122
|
+
"""Test returns None for nomadkaraoke.com without subdomain."""
|
|
123
|
+
tenant_id = middleware._extract_tenant_from_host("nomadkaraoke.com")
|
|
124
|
+
|
|
125
|
+
assert tenant_id is None
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize("subdomain", NON_TENANT_SUBDOMAINS)
|
|
128
|
+
def test_extract_tenant_from_host_non_tenant_subdomains(self, middleware, subdomain):
|
|
129
|
+
"""Test returns None for known non-tenant subdomains."""
|
|
130
|
+
host = f"{subdomain}.nomadkaraoke.com"
|
|
131
|
+
|
|
132
|
+
tenant_id = middleware._extract_tenant_from_host(host)
|
|
133
|
+
|
|
134
|
+
assert tenant_id is None
|
|
135
|
+
|
|
136
|
+
def test_extract_tenant_from_host_tenant_not_exists(self, middleware, mock_tenant_service):
|
|
137
|
+
"""Test returns None when tenant doesn't exist in GCS."""
|
|
138
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
139
|
+
|
|
140
|
+
tenant_id = middleware._extract_tenant_from_host("nonexistent.nomadkaraoke.com")
|
|
141
|
+
|
|
142
|
+
assert tenant_id is None
|
|
143
|
+
|
|
144
|
+
# Tests for _extract_tenant_id()
|
|
145
|
+
def test_extract_tenant_id_from_header(self, middleware):
|
|
146
|
+
"""Test X-Tenant-ID header takes priority."""
|
|
147
|
+
request = MockRequest(
|
|
148
|
+
headers={"X-Tenant-ID": "vocalstar", "Host": "other.nomadkaraoke.com"},
|
|
149
|
+
query_params={"tenant": "different"},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
153
|
+
|
|
154
|
+
assert tenant_id == "vocalstar"
|
|
155
|
+
|
|
156
|
+
def test_extract_tenant_id_header_normalized(self, middleware):
|
|
157
|
+
"""Test header value is normalized (lowercased, stripped)."""
|
|
158
|
+
request = MockRequest(headers={"X-Tenant-ID": " VOCALSTAR "})
|
|
159
|
+
|
|
160
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
161
|
+
|
|
162
|
+
assert tenant_id == "vocalstar"
|
|
163
|
+
|
|
164
|
+
@patch.dict(os.environ, {"ENV": ""})
|
|
165
|
+
def test_extract_tenant_id_from_query_param_dev(self, middleware):
|
|
166
|
+
"""Test query param works in non-production."""
|
|
167
|
+
request = MockRequest(query_params={"tenant": "vocalstar"})
|
|
168
|
+
|
|
169
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
170
|
+
|
|
171
|
+
assert tenant_id == "vocalstar"
|
|
172
|
+
|
|
173
|
+
@patch.dict(os.environ, {"ENV": "production"})
|
|
174
|
+
def test_extract_tenant_id_query_param_disabled_in_prod(self, middleware, mock_tenant_service):
|
|
175
|
+
"""Test query param is ignored in production."""
|
|
176
|
+
# Need to reimport to pick up new env value
|
|
177
|
+
import importlib
|
|
178
|
+
import backend.middleware.tenant as tenant_module
|
|
179
|
+
|
|
180
|
+
# Save original value
|
|
181
|
+
original_is_prod = tenant_module.IS_PRODUCTION
|
|
182
|
+
|
|
183
|
+
# Temporarily set to True for this test
|
|
184
|
+
tenant_module.IS_PRODUCTION = True
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
188
|
+
request = MockRequest(
|
|
189
|
+
query_params={"tenant": "vocalstar"},
|
|
190
|
+
headers={"Host": "gen.nomadkaraoke.com"},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
194
|
+
|
|
195
|
+
# Should not use query param, and gen subdomain is non-tenant
|
|
196
|
+
assert tenant_id is None
|
|
197
|
+
finally:
|
|
198
|
+
# Restore
|
|
199
|
+
tenant_module.IS_PRODUCTION = original_is_prod
|
|
200
|
+
|
|
201
|
+
def test_extract_tenant_id_from_host(self, middleware, mock_tenant_service):
|
|
202
|
+
"""Test falls back to host header subdomain detection."""
|
|
203
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
204
|
+
request = MockRequest(headers={"Host": "vocalstar.nomadkaraoke.com"})
|
|
205
|
+
|
|
206
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
207
|
+
|
|
208
|
+
assert tenant_id == "vocalstar"
|
|
209
|
+
|
|
210
|
+
def test_extract_tenant_id_no_tenant(self, middleware, mock_tenant_service):
|
|
211
|
+
"""Test returns None when no tenant detected."""
|
|
212
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
213
|
+
request = MockRequest(headers={"Host": "gen.nomadkaraoke.com"})
|
|
214
|
+
|
|
215
|
+
tenant_id = middleware._extract_tenant_id(request)
|
|
216
|
+
|
|
217
|
+
assert tenant_id is None
|
|
218
|
+
|
|
219
|
+
# Tests for dispatch()
|
|
220
|
+
@pytest.mark.asyncio
|
|
221
|
+
async def test_dispatch_attaches_tenant_to_state(self, middleware, mock_tenant_service):
|
|
222
|
+
"""Test middleware attaches tenant info to request.state."""
|
|
223
|
+
mock_tenant_service.get_tenant_config.return_value = SAMPLE_CONFIG
|
|
224
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
225
|
+
|
|
226
|
+
request = MockRequest(headers={"X-Tenant-ID": "vocalstar"})
|
|
227
|
+
response = Response(content="OK")
|
|
228
|
+
call_next = AsyncMock(return_value=response)
|
|
229
|
+
|
|
230
|
+
result = await middleware.dispatch(request, call_next)
|
|
231
|
+
|
|
232
|
+
assert request.state.tenant_id == "vocalstar"
|
|
233
|
+
assert request.state.tenant_config == SAMPLE_CONFIG
|
|
234
|
+
|
|
235
|
+
@pytest.mark.asyncio
|
|
236
|
+
async def test_dispatch_adds_header_to_response(self, middleware, mock_tenant_service):
|
|
237
|
+
"""Test middleware adds X-Tenant-ID header to response."""
|
|
238
|
+
mock_tenant_service.get_tenant_config.return_value = SAMPLE_CONFIG
|
|
239
|
+
mock_tenant_service.tenant_exists.return_value = True
|
|
240
|
+
|
|
241
|
+
request = MockRequest(headers={"X-Tenant-ID": "vocalstar"})
|
|
242
|
+
response = Response(content="OK")
|
|
243
|
+
call_next = AsyncMock(return_value=response)
|
|
244
|
+
|
|
245
|
+
result = await middleware.dispatch(request, call_next)
|
|
246
|
+
|
|
247
|
+
assert result.headers.get("X-Tenant-ID") == "vocalstar"
|
|
248
|
+
|
|
249
|
+
@pytest.mark.asyncio
|
|
250
|
+
async def test_dispatch_no_tenant(self, middleware, mock_tenant_service):
|
|
251
|
+
"""Test middleware handles no tenant gracefully."""
|
|
252
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
253
|
+
|
|
254
|
+
request = MockRequest(headers={"Host": "gen.nomadkaraoke.com"})
|
|
255
|
+
response = Response(content="OK")
|
|
256
|
+
call_next = AsyncMock(return_value=response)
|
|
257
|
+
|
|
258
|
+
result = await middleware.dispatch(request, call_next)
|
|
259
|
+
|
|
260
|
+
assert request.state.tenant_id is None
|
|
261
|
+
assert request.state.tenant_config is None
|
|
262
|
+
assert "X-Tenant-ID" not in result.headers
|
|
263
|
+
|
|
264
|
+
@pytest.mark.asyncio
|
|
265
|
+
async def test_dispatch_inactive_tenant(self, middleware, mock_tenant_service):
|
|
266
|
+
"""Test middleware treats inactive tenant as no tenant."""
|
|
267
|
+
mock_tenant_service.get_tenant_config.return_value = INACTIVE_CONFIG
|
|
268
|
+
|
|
269
|
+
request = MockRequest(headers={"X-Tenant-ID": "inactive"})
|
|
270
|
+
response = Response(content="OK")
|
|
271
|
+
call_next = AsyncMock(return_value=response)
|
|
272
|
+
|
|
273
|
+
result = await middleware.dispatch(request, call_next)
|
|
274
|
+
|
|
275
|
+
# Inactive tenant should be treated as default
|
|
276
|
+
assert request.state.tenant_id is None
|
|
277
|
+
assert request.state.tenant_config is None
|
|
278
|
+
|
|
279
|
+
@pytest.mark.asyncio
|
|
280
|
+
async def test_dispatch_calls_next(self, middleware, mock_tenant_service):
|
|
281
|
+
"""Test middleware calls the next handler."""
|
|
282
|
+
mock_tenant_service.tenant_exists.return_value = False
|
|
283
|
+
|
|
284
|
+
request = MockRequest(headers={"Host": "gen.nomadkaraoke.com"})
|
|
285
|
+
response = Response(content="OK")
|
|
286
|
+
call_next = AsyncMock(return_value=response)
|
|
287
|
+
|
|
288
|
+
await middleware.dispatch(request, call_next)
|
|
289
|
+
|
|
290
|
+
call_next.assert_called_once_with(request)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class TestHelperFunctions:
|
|
294
|
+
"""Tests for helper functions."""
|
|
295
|
+
|
|
296
|
+
def test_get_tenant_from_request(self):
|
|
297
|
+
"""Test get_tenant_from_request helper."""
|
|
298
|
+
request = MagicMock()
|
|
299
|
+
request.state.tenant_id = "vocalstar"
|
|
300
|
+
|
|
301
|
+
assert get_tenant_from_request(request) == "vocalstar"
|
|
302
|
+
|
|
303
|
+
def test_get_tenant_from_request_none(self):
|
|
304
|
+
"""Test get_tenant_from_request when no tenant."""
|
|
305
|
+
request = MagicMock()
|
|
306
|
+
del request.state.tenant_id # Simulate missing attribute
|
|
307
|
+
|
|
308
|
+
assert get_tenant_from_request(request) is None
|
|
309
|
+
|
|
310
|
+
def test_get_tenant_config_from_request(self):
|
|
311
|
+
"""Test get_tenant_config_from_request helper."""
|
|
312
|
+
request = MagicMock()
|
|
313
|
+
request.state.tenant_config = SAMPLE_CONFIG
|
|
314
|
+
|
|
315
|
+
config = get_tenant_config_from_request(request)
|
|
316
|
+
|
|
317
|
+
assert config == SAMPLE_CONFIG
|
|
318
|
+
assert config.features.audio_search is False
|
|
319
|
+
|
|
320
|
+
def test_get_tenant_config_from_request_none(self):
|
|
321
|
+
"""Test get_tenant_config_from_request when no config."""
|
|
322
|
+
request = MagicMock()
|
|
323
|
+
del request.state.tenant_config
|
|
324
|
+
|
|
325
|
+
assert get_tenant_config_from_request(request) is None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TestNonTenantSubdomains:
|
|
329
|
+
"""Tests for NON_TENANT_SUBDOMAINS constant."""
|
|
330
|
+
|
|
331
|
+
def test_gen_is_non_tenant(self):
|
|
332
|
+
"""Test 'gen' is in non-tenant subdomains."""
|
|
333
|
+
assert "gen" in NON_TENANT_SUBDOMAINS
|
|
334
|
+
|
|
335
|
+
def test_api_is_non_tenant(self):
|
|
336
|
+
"""Test 'api' is in non-tenant subdomains."""
|
|
337
|
+
assert "api" in NON_TENANT_SUBDOMAINS
|
|
338
|
+
|
|
339
|
+
def test_www_is_non_tenant(self):
|
|
340
|
+
"""Test 'www' is in non-tenant subdomains."""
|
|
341
|
+
assert "www" in NON_TENANT_SUBDOMAINS
|
|
342
|
+
|
|
343
|
+
def test_admin_is_non_tenant(self):
|
|
344
|
+
"""Test 'admin' is in non-tenant subdomains."""
|
|
345
|
+
assert "admin" in NON_TENANT_SUBDOMAINS
|