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