gabriel / musehub public
test_compliance_section9.py python
594 lines 24.7 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """Section 9 — Compliance & Legal Minimums.
2
3 Covers:
4 Privacy policy : exists, linked from footer, covers agent-first model.
5 Terms of Service : exists, implicit acceptance via key registration.
6 Minimum data : no unnecessary PII fields in MusehubIdentity.
7 GDPR / CCPA : GET /me/export and DELETE /me endpoints exist and work.
8 DMCA : takedown process documented.
9 OSS license audit : license-audit.md exists and covers all direct deps.
10 DB migration : 0023 adds training_opt_out + tos_accepted_at/tos_version.
11 Auth service : tos_accepted_at set at registration time.
12 Training opt-out : MusehubRepo.training_opt_out field exists, defaults False.
13 """
14 from __future__ import annotations
15
16 import json
17 from pathlib import Path
18 from unittest.mock import AsyncMock, MagicMock, patch
19
20 import pytest
21
22 _ROOT = Path(__file__).resolve().parents[1]
23 _MUSEHUB_PKG = _ROOT / "musehub"
24 _DOCS_LEGAL = _ROOT / "docs" / "legal"
25 _BASE_HTML = _MUSEHUB_PKG / "templates" / "musehub" / "base.html"
26 _AUTH_SVC = _MUSEHUB_PKG / "services" / "musehub_auth.py"
27 _USERS_ROUTES = _MUSEHUB_PKG / "api" / "routes" / "musehub" / "users.py"
28 _DB_MODELS = _MUSEHUB_PKG / "db" / "musehub_models.py"
29 _MIGRATION = _ROOT / "alembic" / "versions" / "0023_compliance_fields.py"
30 _CHECKLIST = _ROOT / "docs" / "pre-launch-checklist.md"
31
32
33 # ═══════════════════════════════════════════════════════════════════════════════
34 # Privacy Policy
35 # ═══════════════════════════════════════════════════════════════════════════════
36
37 class TestPrivacyPolicy:
38 _pp = _DOCS_LEGAL / "privacy-policy.md"
39
40 def test_privacy_policy_exists(self):
41 assert self._pp.exists(), "docs/legal/privacy-policy.md must exist"
42
43 def test_privacy_policy_covers_pubkey_identity(self):
44 text = self._pp.read_text()
45 assert "public key" in text.lower() or "pubkey" in text.lower()
46
47 def test_privacy_policy_covers_agents(self):
48 text = self._pp.read_text()
49 assert "agent" in text.lower()
50
51 def test_privacy_policy_covers_training_data(self):
52 text = self._pp.read_text()
53 # Must mention training data policy
54 assert "training" in text.lower()
55
56 def test_privacy_policy_covers_training_opt_out(self):
57 text = self._pp.read_text()
58 assert "training_opt_out" in text or "opt-out" in text.lower() or "opt out" in text.lower()
59
60 def test_privacy_policy_mentions_export_endpoint(self):
61 text = self._pp.read_text()
62 assert "/me/export" in text or "export" in text.lower()
63
64 def test_privacy_policy_mentions_delete_endpoint(self):
65 text = self._pp.read_text()
66 assert "DELETE" in text or "deletion" in text.lower()
67
68 def test_privacy_policy_covers_private_repos_exclusion(self):
69 text = self._pp.read_text()
70 # Private repos must never be used for training
71 assert "private" in text.lower()
72
73 def test_privacy_policy_has_effective_date(self):
74 text = self._pp.read_text()
75 assert "Effective date" in text or "effective date" in text.lower()
76
77
78 # ═══════════════════════════════════════════════════════════════════════════════
79 # Terms of Service
80 # ═══════════════════════════════════════════════════════════════════════════════
81
82 class TestTermsOfService:
83 _tos = _DOCS_LEGAL / "terms-of-service.md"
84
85 def test_tos_exists(self):
86 assert self._tos.exists(), "docs/legal/terms-of-service.md must exist"
87
88 def test_tos_implicit_acceptance_via_key_registration(self):
89 text = self._tos.read_text()
90 # Must explain that key registration = acceptance
91 assert "key registration" in text.lower() or "registering a key" in text.lower()
92
93 def test_tos_records_tos_accepted_at(self):
94 text = self._tos.read_text()
95 assert "tos_accepted_at" in text
96
97 def test_tos_records_tos_version(self):
98 text = self._tos.read_text()
99 assert "tos_version" in text
100
101 def test_tos_agent_operator_responsibility(self):
102 text = self._tos.read_text()
103 assert "operator" in text.lower()
104
105 def test_tos_training_data_policy_section(self):
106 text = self._tos.read_text()
107 assert "training" in text.lower()
108
109 def test_tos_private_repos_never_used_for_training(self):
110 text = self._tos.read_text()
111 # The word "private" must appear in context with training
112 assert "private" in text.lower()
113
114 def test_tos_training_opt_out_mentioned(self):
115 text = self._tos.read_text()
116 assert "training_opt_out" in text
117
118 def test_tos_osi_license_condition(self):
119 text = self._tos.read_text()
120 # Must condition training use on OSI license
121 assert "osi" in text.lower() or "open-source license" in text.lower() or "open source license" in text.lower()
122
123 def test_tos_has_effective_date(self):
124 text = self._tos.read_text()
125 assert "Effective date" in text or "effective date" in text.lower()
126
127
128 # ═══════════════════════════════════════════════════════════════════════════════
129 # DMCA
130 # ═══════════════════════════════════════════════════════════════════════════════
131
132 class TestDmca:
133 _dmca = _DOCS_LEGAL / "dmca.md"
134
135 def test_dmca_exists(self):
136 assert self._dmca.exists(), "docs/legal/dmca.md must exist"
137
138 def test_dmca_has_contact_email(self):
139 text = self._dmca.read_text()
140 assert "dmca@" in text or "@musehub" in text
141
142 def test_dmca_has_response_timeline(self):
143 text = self._dmca.read_text()
144 # Must commit to a response time
145 assert "business day" in text.lower() or "days" in text.lower()
146
147 def test_dmca_covers_counter_notice(self):
148 text = self._dmca.read_text()
149 assert "counter" in text.lower()
150
151 def test_dmca_mentions_repeat_infringers(self):
152 text = self._dmca.read_text()
153 assert "repeat" in text.lower()
154
155 def test_dmca_covers_agent_operators(self):
156 text = self._dmca.read_text()
157 assert "agent" in text.lower() or "operator" in text.lower()
158
159
160 # ═══════════════════════════════════════════════════════════════════════════════
161 # OSS License Audit
162 # ═══════════════════════════════════════════════════════════════════════════════
163
164 class TestLicenseAudit:
165 _audit = _DOCS_LEGAL / "license-audit.md"
166
167 def test_license_audit_exists(self):
168 assert self._audit.exists(), "docs/legal/license-audit.md must exist"
169
170 def test_license_audit_covers_fastapi(self):
171 text = self._audit.read_text()
172 assert "fastapi" in text.lower()
173
174 def test_license_audit_covers_sqlalchemy(self):
175 text = self._audit.read_text()
176 assert "sqlalchemy" in text.lower()
177
178 def test_license_audit_covers_cryptography(self):
179 text = self._audit.read_text()
180 assert "cryptography" in text.lower()
181
182 def test_license_audit_covers_psycopg2(self):
183 text = self._audit.read_text()
184 assert "psycopg2" in text.lower()
185
186 def test_license_audit_has_review_schedule(self):
187 text = self._audit.read_text()
188 assert "review" in text.lower()
189
190 def test_all_direct_deps_are_osi_or_noted(self):
191 """Every dep row must declare 'Yes' (OSI) or have an explanation."""
192 text = self._audit.read_text()
193 # We just check that 'OSI approved' header is present and 'Yes' appears
194 assert "OSI approved" in text or "osi" in text.lower()
195
196
197 # ═══════════════════════════════════════════════════════════════════════════════
198 # Footer — legal links in base.html
199 # ═══════════════════════════════════════════════════════════════════════════════
200
201 class TestLegalFooter:
202 def test_footer_exists_in_base_html(self):
203 text = _BASE_HTML.read_text()
204 assert "site-footer" in text or "<footer" in text
205
206 def test_footer_has_privacy_link(self):
207 text = _BASE_HTML.read_text()
208 assert "privacy" in text.lower()
209
210 def test_footer_has_terms_link(self):
211 text = _BASE_HTML.read_text()
212 assert "terms" in text.lower() or "Terms" in text
213
214 def test_footer_has_dmca_link(self):
215 text = _BASE_HTML.read_text()
216 assert "dmca" in text.lower() or "DMCA" in text
217
218
219 # ═══════════════════════════════════════════════════════════════════════════════
220 # DB Model — compliance fields
221 # ═══════════════════════════════════════════════════════════════════════════════
222
223 class TestDbComplianceFields:
224 def test_musehub_repo_has_training_opt_out(self):
225 from musehub.db.musehub_models import MusehubRepo
226 assert hasattr(MusehubRepo, "training_opt_out")
227
228 def test_training_opt_out_defaults_false(self):
229 from musehub.db.musehub_models import MusehubRepo
230 col = MusehubRepo.__table__.c["training_opt_out"]
231 # default is False
232 assert col.default is not None or col.server_default is not None or col.nullable is False
233
234 def test_musehub_identity_has_tos_accepted_at(self):
235 from musehub.db.musehub_models import MusehubIdentity
236 assert hasattr(MusehubIdentity, "tos_accepted_at")
237
238 def test_musehub_identity_has_tos_version(self):
239 from musehub.db.musehub_models import MusehubIdentity
240 assert hasattr(MusehubIdentity, "tos_version")
241
242
243 # ═══════════════════════════════════════════════════════════════════════════════
244 # Alembic migration 0023
245 # ═══════════════════════════════════════════════════════════════════════════════
246
247 class TestMigration0023:
248 def test_migration_file_exists(self):
249 assert _MIGRATION.exists(), "alembic/versions/0023_compliance_fields.py must exist"
250
251 def test_revision_is_0023(self):
252 src = _MIGRATION.read_text()
253 assert 'revision = "0023"' in src
254
255 def test_down_revision_is_0022(self):
256 src = _MIGRATION.read_text()
257 assert 'down_revision = "0022"' in src
258
259 def test_adds_training_opt_out_to_repos(self):
260 src = _MIGRATION.read_text()
261 assert "training_opt_out" in src
262 assert "musehub_repos" in src
263
264 def test_adds_tos_accepted_at_to_identities(self):
265 src = _MIGRATION.read_text()
266 assert "tos_accepted_at" in src
267 assert "musehub_identities" in src
268
269 def test_adds_tos_version_to_identities(self):
270 src = _MIGRATION.read_text()
271 assert "tos_version" in src
272
273 def test_has_downgrade(self):
274 src = _MIGRATION.read_text()
275 assert "def downgrade" in src
276 assert "drop_column" in src
277
278
279 # ═══════════════════════════════════════════════════════════════════════════════
280 # Auth service — tos_accepted_at at registration
281 # ═══════════════════════════════════════════════════════════════════════════════
282
283 class TestAuthTosRecording:
284 def test_auth_sets_tos_accepted_at_on_registration(self):
285 src = _AUTH_SVC.read_text()
286 assert "tos_accepted_at" in src
287
288 def test_auth_sets_tos_version_on_registration(self):
289 src = _AUTH_SVC.read_text()
290 assert "tos_version" in src
291
292 def test_tos_version_is_1_0(self):
293 src = _AUTH_SVC.read_text()
294 assert '"1.0"' in src or "'1.0'" in src
295
296 def test_tos_accepted_at_set_at_identity_creation(self):
297 src = _AUTH_SVC.read_text()
298 # tos_accepted_at should be passed into MusehubIdentity constructor
299 assert "MusehubIdentity(" in src
300 # After MusehubIdentity( the tos_accepted_at should appear before the next session.add
301 idx_construct = src.index("MusehubIdentity(")
302 idx_add = src.index("session.add(identity)", idx_construct)
303 segment = src[idx_construct:idx_add]
304 assert "tos_accepted_at" in segment
305
306
307 # ═══════════════════════════════════════════════════════════════════════════════
308 # GDPR endpoints — source-level checks
309 # ═══════════════════════════════════════════════════════════════════════════════
310
311 class TestGdprEndpointsExist:
312 def test_export_endpoint_defined(self):
313 src = _USERS_ROUTES.read_text()
314 assert "/me/export" in src
315
316 def test_delete_endpoint_defined(self):
317 src = _USERS_ROUTES.read_text()
318 assert '"/me"' in src
319 assert "delete" in src.lower()
320
321 def test_export_requires_auth(self):
322 src = _USERS_ROUTES.read_text()
323 # export endpoint must use require_valid_token
324 # Find the block around /me/export
325 idx = src.index("/me/export")
326 segment = src[max(0, idx - 200):idx + 500]
327 assert "require_valid_token" in segment
328
329 def test_delete_requires_auth(self):
330 src = _USERS_ROUTES.read_text()
331 # Find the delete /me block — look for HTTP_204_NO_CONTENT (unique to delete)
332 assert "HTTP_204_NO_CONTENT" in src
333 idx = src.index("HTTP_204_NO_CONTENT")
334 segment = src[max(0, idx - 500):idx + 1000]
335 assert "require_valid_token" in segment
336
337 def test_export_returns_identity_data(self):
338 src = _USERS_ROUTES.read_text()
339 # Export must include identity, keys, repos, commits
340 assert '"identity"' in src or "\"identity\"" in src
341 assert '"keys"' in src or "\"keys\"" in src
342 assert '"repos"' in src or "\"repos\"" in src
343 assert '"commits"' in src or "\"commits\"" in src
344
345 def test_export_includes_tos_acceptance(self):
346 src = _USERS_ROUTES.read_text()
347 # Export response must include tos_accepted_at
348 assert "tos_accepted_at" in src
349
350 def test_delete_hard_deletes_auth_keys(self):
351 src = _USERS_ROUTES.read_text()
352 # Deletion of auth keys must happen in the delete endpoint
353 assert "MusehubAuthKey" in src
354 assert "delete(" in src or "Delete(" in src
355
356 def test_delete_soft_deletes_repos(self):
357 src = _USERS_ROUTES.read_text()
358 # Repos get soft-deleted (deleted_at = now)
359 assert "deleted_at" in src
360
361 def test_export_schema_version_field(self):
362 src = _USERS_ROUTES.read_text()
363 assert "schema_version" in src
364
365 def test_delete_returns_204(self):
366 src = _USERS_ROUTES.read_text()
367 assert "HTTP_204_NO_CONTENT" in src
368
369 def test_gdpr_import_added_to_users(self):
370 src = _USERS_ROUTES.read_text()
371 assert "MusehubAuthKey" in src
372
373
374 # ═══════════════════════════════════════════════════════════════════════════════
375 # GDPR endpoint integration — unit tests with mocked DB
376 # ═══════════════════════════════════════════════════════════════════════════════
377
378 class TestGdprExportUnit:
379 """Test GET /api/me/export response structure."""
380
381 @pytest.mark.asyncio
382 async def test_export_response_structure(self):
383 from datetime import datetime, timezone
384 from musehub.api.routes.musehub.users import export_my_data
385 from musehub.auth.dependencies import TokenClaims
386
387 now = datetime.now(timezone.utc)
388
389 mock_identity = MagicMock()
390 mock_identity.id = "id-123"
391 mock_identity.handle = "gabriel"
392 mock_identity.identity_type = "human"
393 mock_identity.display_name = "Gabriel"
394 mock_identity.bio = "Music maker"
395 mock_identity.email = None
396 mock_identity.website_url = None
397 mock_identity.location = None
398 mock_identity.created_at = now
399 mock_identity.tos_accepted_at = now
400 mock_identity.tos_version = "1.0"
401
402 mock_key = MagicMock()
403 mock_key.key_id = "key-1"
404 mock_key.algorithm = "ed25519"
405 mock_key.fingerprint = "abc123"
406 mock_key.label = "main"
407 mock_key.created_at = now
408 mock_key.last_used_at = now
409
410 mock_repo = MagicMock()
411 mock_repo.repo_id = "repo-1"
412 mock_repo.name = "test-repo"
413 mock_repo.slug = "test-repo"
414 mock_repo.visibility = "public"
415 mock_repo.description = ""
416 mock_repo.tags = []
417 mock_repo.training_opt_out = False
418 mock_repo.created_at = now
419
420 mock_commit = MagicMock()
421 mock_commit.commit_id = "c-1"
422 mock_commit.repo_id = "repo-1"
423 mock_commit.branch = "main"
424 mock_commit.message = "init"
425 mock_commit.timestamp = now
426
427 # Mock DB session
428 db = AsyncMock()
429
430 def make_result(obj_or_list):
431 r = MagicMock()
432 if isinstance(obj_or_list, list):
433 r.scalars.return_value.all.return_value = obj_or_list
434 else:
435 r.scalar_one_or_none.return_value = obj_or_list
436 return r
437
438 db.execute = AsyncMock(side_effect=[
439 make_result(mock_identity), # identity query
440 make_result([mock_key]), # keys query
441 make_result([mock_repo]), # repos query
442 make_result([mock_commit]), # commits query
443 ])
444
445 claims = MagicMock(spec=TokenClaims)
446 claims.identity_id = "id-123"
447
448 result = await export_my_data(claims=claims, db=db)
449
450 assert result["schema_version"] == "1.0"
451 assert result["identity"]["handle"] == "gabriel"
452 assert result["identity"]["tos_version"] == "1.0"
453 assert len(result["keys"]) == 1
454 assert result["keys"][0]["algorithm"] == "ed25519"
455 assert len(result["repos"]) == 1
456 assert result["repos"][0]["training_opt_out"] is False
457 assert len(result["commits"]) == 1
458
459 @pytest.mark.asyncio
460 async def test_export_404_when_identity_missing(self):
461 from musehub.api.routes.musehub.users import export_my_data
462 from musehub.auth.dependencies import TokenClaims
463 from fastapi import HTTPException
464
465 db = AsyncMock()
466 result = MagicMock()
467 result.scalar_one_or_none.return_value = None
468 db.execute = AsyncMock(return_value=result)
469
470 claims = MagicMock(spec=TokenClaims)
471 claims.identity_id = "missing-id"
472
473 with pytest.raises(HTTPException) as exc_info:
474 await export_my_data(claims=claims, db=db)
475 assert exc_info.value.status_code == 404
476
477
478 class TestGdprDeleteUnit:
479 """Test DELETE /api/me endpoint."""
480
481 @pytest.mark.asyncio
482 async def test_delete_calls_commit(self):
483 from datetime import datetime, timezone
484 from musehub.api.routes.musehub.users import delete_my_account
485 from musehub.auth.dependencies import TokenClaims
486
487 now = datetime.now(timezone.utc)
488
489 mock_identity = MagicMock()
490 mock_identity.id = "id-123"
491 mock_identity.handle = "gabriel"
492 mock_identity.deleted_at = None
493
494 db = AsyncMock()
495
496 identity_result = MagicMock()
497 identity_result.scalar_one_or_none.return_value = mock_identity
498
499 db.execute = AsyncMock(return_value=MagicMock())
500 # First call returns identity, subsequent calls (delete keys, update repos) return MagicMock
501 call_count = 0
502
503 async def execute_side_effect(stmt):
504 nonlocal call_count
505 call_count += 1
506 if call_count == 1:
507 return identity_result
508 return MagicMock()
509
510 db.execute = execute_side_effect
511 db.commit = AsyncMock()
512
513 claims = MagicMock(spec=TokenClaims)
514 claims.identity_id = "id-123"
515
516 await delete_my_account(claims=claims, db=db)
517
518 # commit must have been called
519 db.commit.assert_awaited_once()
520
521 @pytest.mark.asyncio
522 async def test_delete_sets_deleted_at_on_identity(self):
523 from datetime import datetime, timezone
524 from musehub.api.routes.musehub.users import delete_my_account
525 from musehub.auth.dependencies import TokenClaims
526
527 mock_identity = MagicMock()
528 mock_identity.id = "id-123"
529 mock_identity.handle = "gabriel"
530 mock_identity.deleted_at = None
531
532 db = AsyncMock()
533 identity_result = MagicMock()
534 identity_result.scalar_one_or_none.return_value = mock_identity
535
536 call_count = 0
537
538 async def execute_side_effect(stmt):
539 nonlocal call_count
540 call_count += 1
541 if call_count == 1:
542 return identity_result
543 return MagicMock()
544
545 db.execute = execute_side_effect
546 db.commit = AsyncMock()
547
548 claims = MagicMock(spec=TokenClaims)
549 claims.identity_id = "id-123"
550
551 await delete_my_account(claims=claims, db=db)
552
553 # identity.deleted_at must have been set
554 assert mock_identity.deleted_at is not None
555
556 @pytest.mark.asyncio
557 async def test_delete_404_when_identity_missing(self):
558 from musehub.api.routes.musehub.users import delete_my_account
559 from musehub.auth.dependencies import TokenClaims
560 from fastapi import HTTPException
561
562 db = AsyncMock()
563 result = MagicMock()
564 result.scalar_one_or_none.return_value = None
565 db.execute = AsyncMock(return_value=result)
566
567 claims = MagicMock(spec=TokenClaims)
568 claims.identity_id = "missing-id"
569
570 with pytest.raises(HTTPException) as exc_info:
571 await delete_my_account(claims=claims, db=db)
572 assert exc_info.value.status_code == 404
573
574
575 # ═══════════════════════════════════════════════════════════════════════════════
576 # Checklist updated
577 # ═══════════════════════════════════════════════════════════════════════════════
578
579 class TestChecklistSection9:
580 def test_checklist_section9_exists(self):
581 text = _CHECKLIST.read_text()
582 assert "## 9. Compliance" in text
583
584 def test_checklist_has_six_items(self):
585 text = _CHECKLIST.read_text()
586 # Find the section 9 block
587 start = text.index("## 9. Compliance")
588 # End at next ## heading
589 try:
590 end = text.index("\n## ", start + 1)
591 except ValueError:
592 end = len(text)
593 section = text[start:end]
594 assert section.count("- [x]") >= 6, "All 6 section 9 items should be checked"
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago