gabriel / musehub public

test_musehub_ui_user_profile.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """Tests for the enhanced MuseHub user profile page.
2
3 Covers:
4 - test_profile_page_html_returns_200 β€” GET /users/{username} returns 200 HTML
5 - test_profile_page_no_auth_required β€” accessible without authentication
6 - test_profile_page_unknown_user_still_renders β€” unknown username still returns 200 HTML shell
7 - test_profile_page_html_contains_heatmap_js β€” page includes heatmap rendering JavaScript
8 - test_profile_page_html_contains_badge_js β€” page includes badge rendering JavaScript
9 - test_profile_page_html_contains_pinned_js β€” page includes pinned repos JavaScript
10 - test_profile_page_html_contains_activity_tab β€” page includes Activity tab
11 - test_profile_page_json_returns_200 β€” ?format=json returns 200 JSON
12 - test_profile_page_json_unknown_user_404 β€” ?format=json returns 404 for unknown user
13 - test_profile_page_json_heatmap_structure β€” JSON response has heatmap with days/stats
14 - test_profile_page_json_badges_structure β€” JSON response has 8 badges with expected fields
15 - test_profile_page_json_pinned_repos β€” JSON response includes pinned repo cards
16 - test_profile_page_json_activity_empty β€” JSON response returns empty activity for new user
17 - test_profile_page_json_activity_filter β€” ?tab=commits filters activity to commits only
18 - test_profile_page_json_badge_first_commit_earned β€” first_commit badge earned after seeding a commit
19 - test_profile_page_json_camel_case_keys β€” JSON keys are camelCase
20 """
21 from __future__ import annotations
22
23 import pytest
24 from httpx import AsyncClient
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from musehub.db.musehub_identity_models import MusehubIdentity
28 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo
29 from muse.core.types import long_id, now_utc_iso
30
31
32 # ---------------------------------------------------------------------------
33 # Helpers
34 # ---------------------------------------------------------------------------
35
36
37 async def _make_profile(
38 db: AsyncSession,
39 *,
40 username: str = "testuser",
41 user_id: str = "user-profile-test-001",
42 bio: str | None = "Test bio",
43 ) -> MusehubIdentity:
44 """Seed a minimal MusehubIdentity."""
45 profile = MusehubIdentity(
46 identity_id=user_id,
47 handle=username,
48 identity_type="human",
49 bio=bio,
50 avatar_url=None,
51 )
52 db.add(profile)
53 await db.commit()
54 await db.refresh(profile)
55 return profile
56
57
58 async def _make_repo(
59 db: AsyncSession,
60 *,
61 owner_user_id: str = "user-profile-test-001",
62 owner: str = "testuser",
63 name: str = "test-beats",
64 slug: str = "test-beats",
65 visibility: str = "public",
66 ) -> MusehubRepo:
67 """Seed a minimal MusehubRepo."""
68 from datetime import datetime, timezone
69 from musehub.core.genesis import compute_repo_id
70 repo_id = compute_repo_id(owner_user_id, slug, "code", now_utc_iso())
71 repo = MusehubRepo(
72 repo_id=repo_id,
73 name=name,
74 owner=owner,
75 slug=slug,
76 visibility=visibility,
77 owner_user_id=owner_user_id,
78 )
79 db.add(repo)
80 await db.commit()
81 await db.refresh(repo)
82 return repo
83
84
85 # ---------------------------------------------------------------------------
86 # HTML path tests
87 # ---------------------------------------------------------------------------
88
89
90 async def test_profile_page_html_returns_200(
91 client: AsyncClient,
92 db_session: AsyncSession,
93 ) -> None:
94 """GET /users/{username} returns 200 HTML for any username."""
95 await _make_profile(db_session)
96 response = await client.get("/testuser")
97 assert response.status_code == 200
98 assert "text/html" in response.headers["content-type"]
99
100
101 async def test_profile_page_no_auth_required(
102 client: AsyncClient,
103 db_session: AsyncSession,
104 ) -> None:
105 """Profile page is publicly accessible without authentication."""
106 await _make_profile(db_session)
107 response = await client.get("/testuser")
108 assert response.status_code == 200
109
110
111 async def test_profile_page_unknown_user_still_renders(
112 client: AsyncClient,
113 ) -> None:
114 """HTML shell renders even for unknown users β€” data fetched client-side."""
115 response = await client.get("/nobody-exists-xyzzy")
116 assert response.status_code == 200
117 assert "text/html" in response.headers["content-type"]
118
119
120 async def test_profile_page_html_contains_heatmap_js(
121 client: AsyncClient,
122 db_session: AsyncSession,
123 ) -> None:
124 """HTML dispatches the user-profile TypeScript module (heatmap rendered client-side)."""
125 await _make_profile(db_session)
126 response = await client.get("/testuser")
127 assert response.status_code == 200
128 body = response.text
129 # renderHeatmap moved to app.js; page dispatch JSON confirms module will run
130 assert '"page": "user-profile"' in body
131 assert '"username": "testuser"' in body
132
133
134 async def test_profile_page_html_contains_badge_js(
135 client: AsyncClient,
136 db_session: AsyncSession,
137 ) -> None:
138 """HTML dispatches user-profile module which renders badges client-side."""
139 await _make_profile(db_session)
140 response = await client.get("/testuser")
141 assert response.status_code == 200
142 body = response.text
143 # renderBadges moved to app.js; verify page dispatch and profile container
144 assert '"page": "user-profile"' in body
145 assert "profile-container" in body or "content" in body
146
147
148 async def test_profile_page_html_contains_pinned_js(
149 client: AsyncClient,
150 db_session: AsyncSession,
151 ) -> None:
152 """HTML dispatches user-profile module which renders pinned repos client-side."""
153 await _make_profile(db_session)
154 response = await client.get("/testuser")
155 assert response.status_code == 200
156 body = response.text
157 # renderPinned moved to app.js; verify page dispatch JSON
158 assert '"page": "user-profile"' in body
159 assert "testuser" in body
160
161
162 async def test_profile_page_html_contains_activity_tab(
163 client: AsyncClient,
164 db_session: AsyncSession,
165 ) -> None:
166 """HTML renders the profile page with user-profile page dispatch (activity driven by JS)."""
167 await _make_profile(db_session)
168 response = await client.get("/testuser")
169 assert response.status_code == 200
170 body = response.text
171 # Reimagined template: activity sections are data-driven; module dispatch always present
172 assert '"page": "user-profile"' in body
173 assert "testuser" in body
174
175
176 # ---------------------------------------------------------------------------
177 # JSON path tests
178 # ---------------------------------------------------------------------------
179
180
181 async def test_profile_page_json_returns_200(
182 client: AsyncClient,
183 db_session: AsyncSession,
184 ) -> None:
185 """GET /users/{username}?format=json returns 200 JSON."""
186 await _make_profile(db_session)
187 response = await client.get("/testuser?format=json")
188 assert response.status_code == 200
189 assert "application/json" in response.headers["content-type"]
190
191
192 async def test_profile_page_json_unknown_user_404(
193 client: AsyncClient,
194 ) -> None:
195 """?format=json returns 404 for an unknown username."""
196 response = await client.get("/nobody-exists-xyzzy?format=json")
197 assert response.status_code == 404
198
199
200 async def test_profile_page_json_heatmap_structure(
201 client: AsyncClient,
202 db_session: AsyncSession,
203 ) -> None:
204 """JSON response contains heatmap with days list and aggregate stats."""
205 await _make_profile(db_session)
206 response = await client.get("/testuser?format=json")
207 assert response.status_code == 200
208 body = response.json()
209
210 assert "heatmap" in body
211 heatmap = body["heatmap"]
212 assert "days" in heatmap
213 assert "totalContributions" in heatmap
214 assert "longestStreak" in heatmap
215 assert "currentStreak" in heatmap
216
217 # Should have ~364 days (52 weeks Γ— 7 days)
218 assert len(heatmap["days"]) >= 360
219
220 # Each day has date, count, intensity
221 first_day = heatmap["days"][0]
222 assert "date" in first_day
223 assert "count" in first_day
224 assert "intensity" in first_day
225 assert first_day["intensity"] in (0, 1, 2, 3)
226
227
228 async def test_profile_page_json_badges_structure(
229 client: AsyncClient,
230 db_session: AsyncSession,
231 ) -> None:
232 """JSON response contains exactly 8 badges with required fields."""
233 await _make_profile(db_session)
234 response = await client.get("/testuser?format=json")
235 assert response.status_code == 200
236 body = response.json()
237
238 assert "badges" in body
239 badges = body["badges"]
240 assert len(badges) == 8
241
242 for badge in badges:
243 assert "id" in badge
244 assert "name" in badge
245 assert "description" in badge
246 assert "icon" in badge
247 assert "earned" in badge
248 assert isinstance(badge["earned"], bool)
249
250
251 async def test_profile_page_json_pinned_repos(
252 client: AsyncClient,
253 db_session: AsyncSession,
254 ) -> None:
255 """JSON response includes pinned repo cards when pinned_repo_ids are set."""
256 profile = await _make_profile(db_session)
257 repo = await _make_repo(db_session)
258
259 # Pin the repo
260 profile.pinned_repo_ids = [repo.repo_id]
261 db_session.add(profile)
262 await db_session.commit()
263
264 response = await client.get("/testuser?format=json")
265 assert response.status_code == 200
266 body = response.json()
267
268 assert "pinnedRepos" in body
269 pinned = body["pinnedRepos"]
270 assert len(pinned) == 1
271 card = pinned[0]
272 assert card["name"] == "test-beats"
273 assert card["slug"] == "test-beats"
274 assert "forkCount" in card
275
276
277 async def test_profile_page_json_activity_empty(
278 client: AsyncClient,
279 db_session: AsyncSession,
280 ) -> None:
281 """JSON response returns empty activity list for a new user with no events."""
282 await _make_profile(db_session)
283 response = await client.get("/testuser?format=json")
284 assert response.status_code == 200
285 body = response.json()
286
287 assert "activity" in body
288 assert isinstance(body["activity"], list)
289 assert body["totalEvents"] == 0
290 assert body["page"] == 1
291 assert body["perPage"] == 20
292
293
294 async def test_profile_page_json_activity_filter(
295 client: AsyncClient,
296 db_session: AsyncSession,
297 ) -> None:
298 """?tab=commits filters activity response to commits-only event types."""
299 await _make_profile(db_session)
300 response = await client.get("/testuser?format=json&tab=commits")
301 assert response.status_code == 200
302 body = response.json()
303 assert body["activityFilter"] == "commits"
304
305
306 async def test_profile_page_json_badge_first_commit_earned(
307 client: AsyncClient,
308 db_session: AsyncSession,
309 ) -> None:
310 """first_commit badge is earned after the user has at least one commit."""
311 from datetime import datetime, timezone
312
313 profile = await _make_profile(db_session)
314 repo = await _make_repo(db_session)
315
316 # Seed one commit owned by this user's repo
317 commit = MusehubCommit(
318 commit_id="abc123def456abc123def456abc123def456abc1",
319 branch="main",
320 parent_ids=[],
321 message="initial commit",
322 author="testuser",
323 timestamp=datetime.now(tz=timezone.utc),
324 )
325 db_session.add(commit)
326 db_session.add(MusehubCommitRef(repo_id=str(repo.repo_id), commit_id="abc123def456abc123def456abc123def456abc1"))
327 await db_session.commit()
328
329 response = await client.get("/testuser?format=json")
330 assert response.status_code == 200
331 body = response.json()
332
333 badges = {b["id"]: b for b in body["badges"]}
334 assert "first_commit" in badges
335 assert badges["first_commit"]["earned"] is True
336
337
338 async def test_profile_page_json_camel_case_keys(
339 client: AsyncClient,
340 db_session: AsyncSession,
341 ) -> None:
342 """JSON response uses camelCase keys throughout (no snake_case at top level)."""
343 await _make_profile(db_session)
344 response = await client.get("/testuser?format=json")
345 assert response.status_code == 200
346 body = response.json()
347
348 # Top-level camelCase keys
349 assert "avatarUrl" in body
350 assert "totalEvents" in body
351 assert "activityFilter" in body
352 assert "pinnedRepos" in body
353
354 # No snake_case variants
355 assert "avatar_url" not in body
356 assert "total_events" not in body
357 assert "pinned_repos" not in body
358
359
360 # ---------------------------------------------------------------------------
361 # AVAX address visibility
362 # ---------------------------------------------------------------------------
363
364
365 async def test_profile_page_html_hides_avax_when_null(
366 client: AsyncClient,
367 db_session: AsyncSession,
368 ) -> None:
369 """Profile HTML does not mention AVAX when avax_address is None."""
370 await _make_profile(db_session)
371 response = await client.get("/testuser")
372 assert response.status_code == 200
373 body = response.text
374 assert "AVAX" not in body
375 assert "avax" not in body.lower()
376
377
378 # ---------------------------------------------------------------------------
379 # Issue #448 β€” rich artist profiles with CC attribution fields
380 # ---------------------------------------------------------------------------
381
382
383 async def test_profile_model_rich_fields_stored_and_retrieved(
384 db_session: AsyncSession,
385 ) -> None:
386 """MusehubIdentity stores and retrieves all CC-attribution fields added.
387
388 Regression: before this fix, display_name / location / website_url /
389 social_url / is_verified / cc_license did not exist on the model or
390 schema; saving them would silently discard the data.
391 """
392 profile = MusehubIdentity(
393 identity_id="user-test-cc-001",
394 handle="kevin_macleod_test",
395 display_name="Kevin MacLeod",
396 bio="Prolific composer. Every genre. Royalty-free forever.",
397 location="Sandpoint, Idaho",
398 website_url="https://incompetech.com",
399 social_url="kmacleod",
400 is_verified=True,
401 cc_license="CC BY 4.0",
402 )
403 db_session.add(profile)
404 await db_session.commit()
405 await db_session.refresh(profile)
406
407 assert profile.display_name == "Kevin MacLeod"
408 assert profile.location == "Sandpoint, Idaho"
409 assert profile.website_url == "https://incompetech.com"
410 assert profile.social_url == "kmacleod"
411 assert profile.is_verified is True
412 assert profile.cc_license == "CC BY 4.0"
413
414
415 async def test_profile_model_verified_defaults_false(
416 db_session: AsyncSession,
417 ) -> None:
418 """is_verified defaults to False for community users β€” no accidental verification."""
419 profile = MusehubIdentity(
420 identity_id="user-test-community-002",
421 handle="community_user_test",
422 bio="Just a regular community user.",
423 )
424 db_session.add(profile)
425 await db_session.commit()
426 await db_session.refresh(profile)
427
428 assert profile.is_verified is False
429 assert profile.cc_license is None
430 assert profile.display_name is None
431 assert profile.location is None
432 assert profile.social_url is None
433
434
435 async def test_profile_model_public_domain_artist(
436 db_session: AsyncSession,
437 ) -> None:
438 """Public Domain composers get is_verified=True and cc_license='Public Domain'."""
439 profile = MusehubIdentity(
440 identity_id="user-test-bach-003",
441 handle="bach_test",
442 display_name="Johann Sebastian Bach",
443 bio="Baroque composer. 48 preludes, 48 fugues.",
444 location="Leipzig, Saxony (1723-1750)",
445 website_url="https://www.bach-digital.de",
446 social_url=None,
447 is_verified=True,
448 cc_license="Public Domain",
449 )
450 db_session.add(profile)
451 await db_session.commit()
452 await db_session.refresh(profile)
453
454 assert profile.is_verified is True
455 assert profile.cc_license == "Public Domain"
456 assert profile.social_url is None
457
458
459 async def test_profile_page_json_includes_verified_and_license(
460 client: AsyncClient,
461 db_session: AsyncSession,
462 ) -> None:
463 """Profile JSON endpoint exposes isVerified and ccLicense fields for CC artists."""
464 profile = MusehubIdentity(
465 identity_id="user-test-cc-api-004",
466 handle="kai_engel_test",
467 display_name="Kai Engel",
468 bio="Ambient architect. Long-form textures.",
469 location="Germany",
470 website_url="https://freemusicarchive.org/music/Kai_Engel",
471 social_url=None,
472 is_verified=True,
473 cc_license="CC BY 4.0",
474 )
475 db_session.add(profile)
476 await db_session.commit()
477
478 response = await client.get("/kai_engel_test?format=json")
479 assert response.status_code == 200
480 body = response.json()
481
482 # The profile card must surface verification status and license so the
483 # frontend can render the CC badge without a secondary API call.
484 assert body.get("isVerified") is True
485 assert body.get("ccLicense") == "CC BY 4.0"
486
487
488 # ===========================================================================
489 # Profile Header Reimagination β€” TDD tests (Issue #1)
490 # Phase 1: repos pipeline (owner query)
491 # Phase 2: bio field
492 # Phase 3: AVAX address
493 # Phase 4: repo chip domain icons
494 # ===========================================================================
495
496 # ---------------------------------------------------------------------------
497 # Helpers shared by header tests
498 # ---------------------------------------------------------------------------
499
500 async def _make_identity_with_repos(
501 db: AsyncSession,
502 *,
503 handle: str = "herouser",
504 bio: str | None = None,
505 avax_address: str | None = None,
506 repo_slugs: list[str] | None = None,
507 ) -> MusehubIdentity:
508 """Seed a MusehubIdentity + repos where owner==handle (realistic data shape).
509
510 owner_user_id is set to the handle string β€” matching production data where
511 repos were created before the identity_id was stable. The repo pipeline fix
512 must resolve repos via owner==handle, not owner_user_id==identity_id.
513 """
514 from datetime import datetime, timezone
515 from musehub.core.genesis import compute_repo_id
516
517 now_iso = now_utc_iso()
518 identity_id = long_id(handle.ljust(64, "0")[:64])
519 profile = MusehubIdentity(
520 identity_id=identity_id,
521 handle=handle,
522 identity_type="human",
523 bio=bio,
524 avax_address=avax_address,
525 avatar_url=None,
526 )
527 db.add(profile)
528 await db.flush()
529
530 for slug in (repo_slugs or []):
531 repo_id = compute_repo_id(identity_id, slug, "code", now_iso)
532 repo = MusehubRepo(
533 repo_id=repo_id,
534 name=slug,
535 owner=handle,
536 slug=slug,
537 visibility="public",
538 # owner_user_id stores the handle string (current production data shape)
539 owner_user_id=handle,
540 )
541 db.add(repo)
542
543 await db.commit()
544 await db.refresh(profile)
545 return profile
546
547
548 # ---------------------------------------------------------------------------
549 # Phase 1 β€” repos pipeline: repos appear in HTML when owner==handle
550 # ---------------------------------------------------------------------------
551
552
553 async def test_profile_header_repos_appear_when_owner_matches_handle(
554 client: AsyncClient,
555 db_session: AsyncSession,
556 ) -> None:
557 """Repo chips render in header when repos.owner == identity.handle.
558
559 Root cause being fixed: _fetch_repos queried owner_user_id==identity_id
560 (sha256:...) but DB stores owner_user_id==handle string. The fix queries
561 owner==handle so repos always resolve correctly.
562 """
563 await _make_identity_with_repos(
564 db_session,
565 handle="chipuser",
566 repo_slugs=["muse", "stori", "maestro"],
567 )
568 resp = await client.get("/chipuser")
569 assert resp.status_code == 200
570 body = resp.text
571 # All three repo slugs must appear as chip text in the hero
572 assert "MUSE" in body
573 assert "STORI" in body
574 assert "MAESTRO" in body
575
576
577 async def test_profile_header_repo_count_in_json(
578 client: AsyncClient,
579 db_session: AsyncSession,
580 ) -> None:
581 """JSON response repoCount matches seeded repos when owner==handle."""
582 await _make_identity_with_repos(
583 db_session,
584 handle="countuser",
585 repo_slugs=["alpha", "beta", "gamma"],
586 )
587 resp = await client.get("/countuser?format=json")
588 assert resp.status_code == 200
589 data = resp.json()
590 assert data.get("repoCount", 0) == 3
591
592
593 async def test_profile_header_repo_count_excludes_private(
594 client: AsyncClient,
595 db_session: AsyncSession,
596 ) -> None:
597 """repoCount on the profile page must only count public repos.
598
599 Regression guard: the query previously lacked a visibility filter, causing
600 private repos to inflate the displayed count.
601 """
602 from musehub.core.genesis import compute_repo_id
603
604 now_iso = now_utc_iso()
605 handle = "privacyuser"
606 identity_id = long_id(handle.ljust(64, "0")[:64])
607 profile = MusehubIdentity(
608 identity_id=identity_id,
609 handle=handle,
610 identity_type="human",
611 )
612 db_session.add(profile)
613 await db_session.flush()
614
615 for slug, visibility in [("pub1", "public"), ("pub2", "public"), ("priv1", "private")]:
616 repo = MusehubRepo(
617 repo_id=compute_repo_id(identity_id, slug, "code", now_iso),
618 name=slug,
619 owner=handle,
620 slug=slug,
621 visibility=visibility,
622 owner_user_id=handle,
623 )
624 db_session.add(repo)
625
626 await db_session.commit()
627
628 resp = await client.get(f"/{handle}?format=json")
629 assert resp.status_code == 200
630 data = resp.json()
631 assert data.get("repoCount") == 2, (
632 f"Expected 2 (public only), got {data.get('repoCount')} β€” "
633 "private repos must be excluded from the profile repo count"
634 )
635
636
637 # ---------------------------------------------------------------------------
638 # Phase 2 β€” bio field: bio renders in header when set
639 # ---------------------------------------------------------------------------
640
641
642 async def test_profile_header_bio_renders_when_set(
643 client: AsyncClient,
644 db_session: AsyncSession,
645 ) -> None:
646 """Bio string appears quoted in the hero body when identity.bio is set."""
647 await _make_identity_with_repos(
648 db_session,
649 handle="biouser",
650 bio="Building the sound of the future",
651 )
652 resp = await client.get("/biouser")
653 assert resp.status_code == 200
654 assert "Building the sound of the future" in resp.text
655
656
657 async def test_profile_header_bio_fallback_when_null(
658 client: AsyncClient,
659 db_session: AsyncSession,
660 ) -> None:
661 """When bio is NULL, the fallback 'member since' line renders instead."""
662 await _make_identity_with_repos(db_session, handle="nobiouser", bio=None)
663 resp = await client.get("/nobiouser")
664 assert resp.status_code == 200
665 assert "member since" in resp.text
666
667
668 async def test_profile_header_avax_hidden_when_null(
669 client: AsyncClient,
670 db_session: AsyncSession,
671 ) -> None:
672 """When avax_address is NULL, AVAX row is hidden entirely β€” no 'not set' placeholder."""
673 await _make_identity_with_repos(db_session, handle="noavaxuser", avax_address=None)
674 resp = await client.get("/noavaxuser")
675 assert resp.status_code == 200
676 assert "AVAX" not in resp.text
677 assert "not set" not in resp.text
678
679
680 # ---------------------------------------------------------------------------
681 # Auth key display β€” identity_id / fingerprint / public_key_b64
682 # ---------------------------------------------------------------------------
683 # These tests lock in the three-field identity model documented in
684 # /muse/identity#key-rotation:
685 # identity_id β€” immutable, sha256(first_registered_key_bytes)
686 # fingerprint β€” per-key, sha256(current_key_bytes), changes on rotation
687 # public_key_b64 β€” raw Ed25519 key, base64url, changes on rotation
688 # ---------------------------------------------------------------------------
689
690
691 async def _make_auth_key(
692 db: AsyncSession,
693 *,
694 identity_id: str,
695 fingerprint: str,
696 public_key_b64: str,
697 algorithm: str = "ed25519",
698 label: str = "",
699 created_at_offset_seconds: int = 0,
700 ) -> None:
701 """Insert a MusehubAuthKey row directly β€” bypasses the challenge-response flow."""
702 from datetime import datetime, timezone, timedelta
703 from musehub.db.musehub_auth_models import MusehubAuthKey
704
705 now = datetime.now(timezone.utc) + timedelta(seconds=created_at_offset_seconds)
706 key = MusehubAuthKey(
707 key_id=fingerprint, # key_id == fingerprint for simplicity in tests
708 identity_id=identity_id,
709 public_key_b64=public_key_b64,
710 fingerprint=fingerprint,
711 algorithm=algorithm,
712 label=label,
713 created_at=now,
714 )
715 db.add(key)
716 await db.flush()
717
718
719 async def test_profile_shows_auth_key_when_registered(
720 client: AsyncClient,
721 db_session: AsyncSession,
722 ) -> None:
723 """Profile hero strip shows algorithm, public_key_b64, and fingerprint
724 when a MusehubAuthKey row exists for the identity."""
725 identity = await _make_identity_with_repos(db_session, handle="keyuser")
726 pubkey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
727 fp = "sha256:aaaa000000000000000000000000000000000000000000000000000000000001"
728
729 await _make_auth_key(
730 db_session,
731 identity_id=identity.identity_id,
732 fingerprint=fp,
733 public_key_b64=pubkey,
734 )
735 await db_session.commit()
736
737 resp = await client.get("/keyuser")
738 assert resp.status_code == 200
739 body = resp.text
740
741 assert "ed25519" in body
742 assert pubkey in body
743 assert fp in body
744
745
746 async def test_profile_fallback_to_identity_id_when_no_key(
747 client: AsyncClient,
748 db_session: AsyncSession,
749 ) -> None:
750 """When no MusehubAuthKey row exists, the profile falls back to displaying
751 the identity_id as the fingerprint β€” clearly a degraded state."""
752 identity = await _make_identity_with_repos(db_session, handle="nokeyuser")
753
754 resp = await client.get("/nokeyuser")
755 assert resp.status_code == 200
756 body = resp.text
757
758 # Falls back to identity.user_id (== identity_id)
759 assert identity.identity_id in body
760 # No pubkey row shown β€” ed25519 label should not appear in strip context
761 # (it may appear elsewhere in the page for other reasons, so we check
762 # that the strip row with the pubkey value is absent)
763 assert "strip-val--mono" in body # strip is rendered
764 # The fallback shows identity_id, not a separate pubkey line
765 assert f'<span class="strip-label">ed25519</span>' not in body
766
767
768 async def test_profile_shows_most_recent_key_after_rotation(
769 client: AsyncClient,
770 db_session: AsyncSession,
771 ) -> None:
772 """After key rotation, the profile shows the newest key, not the original.
773
774 Both keys share the same identity_id β€” this is the rotation invariant.
775 The original key is still valid but the profile surfaces the current one.
776 """
777 identity = await _make_identity_with_repos(db_session, handle="rotateduser")
778
779 old_pubkey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
780 old_fp = "sha256:aaaa000000000000000000000000000000000000000000000000000000000001"
781 new_pubkey = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
782 new_fp = "sha256:bbbb000000000000000000000000000000000000000000000000000000000002"
783
784 # Old key registered first
785 await _make_auth_key(
786 db_session,
787 identity_id=identity.identity_id,
788 fingerprint=old_fp,
789 public_key_b64=old_pubkey,
790 label="original",
791 created_at_offset_seconds=0,
792 )
793 # New key registered 60s later β€” simulates muse auth rotate
794 await _make_auth_key(
795 db_session,
796 identity_id=identity.identity_id,
797 fingerprint=new_fp,
798 public_key_b64=new_pubkey,
799 label="rotated",
800 created_at_offset_seconds=60,
801 )
802 await db_session.commit()
803
804 resp = await client.get("/rotateduser")
805 assert resp.status_code == 200
806 body = resp.text
807
808 # New key displayed
809 assert new_pubkey in body
810 assert new_fp in body
811 # Old key NOT displayed β€” it's registered but not the current one
812 assert old_pubkey not in body
813 assert old_fp not in body
814
815
816 async def test_identity_id_unchanged_across_rotation(
817 client: AsyncClient,
818 db_session: AsyncSession,
819 ) -> None:
820 """The identity_id anchor never changes across key rotations.
821
822 Two keys exist with different fingerprints but the same identity_id β€”
823 both rows link back to the single musehub_identities row.
824 """
825 from musehub.db.musehub_auth_models import MusehubAuthKey
826 from sqlalchemy import select
827
828 identity = await _make_identity_with_repos(db_session, handle="stableuser")
829
830 fp1 = "sha256:cccc000000000000000000000000000000000000000000000000000000000001"
831 fp2 = "sha256:dddd000000000000000000000000000000000000000000000000000000000002"
832
833 await _make_auth_key(
834 db_session,
835 identity_id=identity.identity_id,
836 fingerprint=fp1,
837 public_key_b64="CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
838 created_at_offset_seconds=0,
839 )
840 await _make_auth_key(
841 db_session,
842 identity_id=identity.identity_id,
843 fingerprint=fp2,
844 public_key_b64="DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
845 created_at_offset_seconds=60,
846 )
847 await db_session.commit()
848
849 # Both key rows must reference the same identity_id
850 rows = (await db_session.execute(
851 select(MusehubAuthKey)
852 .where(MusehubAuthKey.identity_id == identity.identity_id)
853 .order_by(MusehubAuthKey.created_at)
854 )).scalars().all()
855
856 assert len(rows) == 2
857 assert rows[0].identity_id == identity.identity_id
858 assert rows[1].identity_id == identity.identity_id
859 assert rows[0].fingerprint == fp1
860 assert rows[1].fingerprint == fp2
861 # identity_id is not a fingerprint of either current key
862 # (it is the fingerprint of the original registration key)
863 assert identity.identity_id not in (fp1, fp2)
864
865
866 async def test_profile_auth_key_algorithm_label_present(
867 client: AsyncClient,
868 db_session: AsyncSession,
869 ) -> None:
870 """The algorithm label ('ed25519') renders as a strip-label element,
871 not as raw text mixed into the fingerprint row."""
872 identity = await _make_identity_with_repos(db_session, handle="algolabeluser")
873 fp = "sha256:eeee000000000000000000000000000000000000000000000000000000000003"
874
875 await _make_auth_key(
876 db_session,
877 identity_id=identity.identity_id,
878 fingerprint=fp,
879 public_key_b64="EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE",
880 algorithm="ed25519",
881 )
882 await db_session.commit()
883
884 resp = await client.get("/algolabeluser")
885 assert resp.status_code == 200
886 body = resp.text
887
888 # Algorithm appears as a strip-label, fingerprint on its own row
889 assert '<span class="strip-label">ed25519</span>' in body
890 assert '<span class="strip-label">fingerprint</span>' in body
891
892
893 # ---------------------------------------------------------------------------