gabriel / musehub public
test_musehub_ui_team.py python
267 lines 9.2 KB
Raw
sha256:dc28fb2384d12a52d4b4fea7743873940b89d9d08ce298f96d0fdc8d694724d4 test+types: green the musehub suite and ratchet typing audi… Opus 4.8 minor ⚠ breaking 14 hours ago
1 """Tests for the MuseHub collaborators/team management UI page (SSR).
2
3 Covers — GET /{owner}/{repo_slug}/settings/collaborators
4
5 Test index:
6 - test_collaborators_settings_page_returns_200
7 GET the settings/collaborators page as the owner returns 200 HTML.
8 - test_collaborators_settings_page_requires_auth
9 The page is owner-only — an unauthenticated GET returns 401.
10 - test_collaborators_settings_page_unknown_repo_404
11 Unknown owner/slug combination returns 404.
12 - test_collaborators_settings_page_has_invite_form_htmx
13 The page embeds the invite form with hx-post attribute.
14 - test_collaborators_settings_page_has_permission_badges
15 The page renders colour-coded permission badge CSS classes.
16 - test_collaborators_settings_page_has_owner_crown_badge
17 The page marks owner permission with a crown emoji (👑).
18 - test_collaborators_settings_page_has_remove_button_htmx
19 Each non-owner row has an hx-delete remove form.
20 - test_collaborators_settings_json_response_empty
21 ?format=json returns CollaboratorListResponse with empty list for new repo.
22 - test_collaborators_settings_json_response_with_collaborators
23 ?format=json returns collaborators seeded in the DB.
24 - test_collaborators_settings_page_has_settings_tabs
25 The page includes the settings tab navigation bar.
26 - test_collaborators_settings_page_has_invite_form_fields
27 The invite form contains user_id and permission input fields.
28 """
29 from __future__ import annotations
30
31 import secrets
32
33 import pytest
34 import pytest_asyncio
35 from httpx import AsyncClient
36 from sqlalchemy.ext.asyncio import AsyncSession
37
38 from datetime import datetime, timezone
39
40 from muse.core.types import now_utc_iso
41 from musehub.core.genesis import compute_collaborator_id, compute_identity_id, compute_repo_id
42 from musehub.db.musehub_collaborator_models import MusehubCollaborator
43 from musehub.db.musehub_repo_models import MusehubRepo
44
45
46
47
48 # ---------------------------------------------------------------------------
49 # Helpers
50 # ---------------------------------------------------------------------------
51
52 _OWNER = "testuser"
53 _SLUG = "collab-test-repo"
54
55
56 async def _make_repo(db_session: AsyncSession) -> str:
57 """Seed a minimal repo for collaborator tests and return its repo_id."""
58 owner_id = compute_identity_id(_OWNER.encode())
59 created_at = datetime.now(tz=timezone.utc)
60 repo = MusehubRepo(
61 repo_id=compute_repo_id(owner_id, _SLUG, "code", created_at.isoformat()),
62 name=_SLUG,
63 owner=_OWNER,
64 slug=_SLUG,
65 visibility="private",
66 owner_user_id=owner_id,
67 created_at=created_at,
68 updated_at=created_at,
69 )
70 db_session.add(repo)
71 await db_session.commit()
72 await db_session.refresh(repo)
73 return str(repo.repo_id)
74
75
76 async def _add_collaborator(
77 db_session: AsyncSession,
78 repo_id: str,
79 user_id: str,
80 permission: str = "write",
81 invited_by: str | None = None,
82 ) -> MusehubCollaborator:
83 """Seed a collaborator record and return it."""
84 identity_id = compute_identity_id(user_id.encode())
85 collab = MusehubCollaborator(
86 id=compute_collaborator_id(repo_id, identity_id, now_utc_iso()),
87 repo_id=repo_id,
88 identity_handle=user_id,
89 permission=permission,
90 invited_by_handle=invited_by,
91 )
92 db_session.add(collab)
93 await db_session.commit()
94 await db_session.refresh(collab)
95 return collab
96
97
98 # ---------------------------------------------------------------------------
99 # Tests
100 # ---------------------------------------------------------------------------
101
102
103 async def test_collaborators_settings_page_returns_200(
104 client: AsyncClient,
105 db_session: AsyncSession,
106 auth_headers: dict[str, str],
107 ) -> None:
108 """GET /{owner}/{slug}/settings/collaborators returns 200 HTML."""
109 await _make_repo(db_session)
110 resp = await client.get(f"/{_OWNER}/{_SLUG}/settings/collaborators")
111 assert resp.status_code == 200
112 assert "text/html" in resp.headers["content-type"]
113
114
115 async def test_collaborators_settings_page_requires_auth(
116 client: AsyncClient,
117 db_session: AsyncSession,
118 ) -> None:
119 """The settings/collaborators page is owner-only: an unauthenticated GET returns 401.
120
121 The page was migrated to a server-side owner gate (require_valid_token +
122 claims.handle == owner); it no longer renders a shell for anonymous callers.
123 """
124 await _make_repo(db_session)
125 resp = await client.get(
126 f"/{_OWNER}/{_SLUG}/settings/collaborators",
127 headers={}, # explicit: no Authorization header
128 )
129 assert resp.status_code == 401
130
131
132 async def test_collaborators_settings_page_unknown_repo_404(
133 client: AsyncClient,
134 db_session: AsyncSession,
135 auth_headers: dict[str, str],
136 ) -> None:
137 """Unknown repo (owner matches the caller, slug not found) returns 404.
138
139 The owner gate (claims.handle == owner) fires before the repo lookup, so the
140 404 path is only reachable when the caller owns the namespace.
141 """
142 resp = await client.get(f"/{_OWNER}/nonexistent-repo/settings/collaborators")
143 assert resp.status_code == 404
144
145
146 async def test_collaborators_settings_page_has_invite_form_htmx(
147 client: AsyncClient,
148 db_session: AsyncSession,
149 auth_headers: dict[str, str],
150 ) -> None:
151 """The page embeds the invite form with hx-post for HTMX submission."""
152 await _make_repo(db_session)
153 resp = await client.get(f"/{_OWNER}/{_SLUG}/settings/collaborators")
154 assert resp.status_code == 200
155 assert "hx-post" in resp.text
156
157
158 async def test_collaborators_settings_page_has_permission_badges(
159 client: AsyncClient,
160 db_session: AsyncSession,
161 auth_headers: dict[str, str],
162 ) -> None:
163 """The page renders colour-coded permission badge CSS classes server-side."""
164 await _make_repo(db_session)
165 resp = await client.get(f"/{_OWNER}/{_SLUG}/settings/collaborators")
166 assert resp.status_code == 200
167 body = resp.text
168 assert "badge-perm-read" in body
169 assert "badge-perm-write" in body
170 assert "badge-perm-admin" in body
171 assert "badge-perm-owner" in body
172
173
174 async def test_collaborators_settings_page_has_owner_crown_badge(
175 client: AsyncClient,
176 db_session: AsyncSession,
177 auth_headers: dict[str, str],
178 ) -> None:
179 """The page marks owner permission with a crown emoji (👑)."""
180 await _make_repo(db_session)
181 resp = await client.get(f"/{_OWNER}/{_SLUG}/settings/collaborators")
182 assert resp.status_code == 200
183 assert "👑" in resp.text
184
185
186 async def test_collaborators_settings_page_has_remove_button_htmx(
187 client: AsyncClient,
188 db_session: AsyncSession,
189 auth_headers: dict[str, str],
190 ) -> None:
191 """Non-owner collaborator rows carry hx-delete on the remove form."""
192 repo_id = await _make_repo(db_session)
193 await _add_collaborator(db_session, repo_id, user_id=f"collab-{secrets.token_hex(4)}", permission="write")
194 resp = await client.get(f"/{_OWNER}/{_SLUG}/settings/collaborators")
195 assert resp.status_code == 200
196 assert "hx-delete" in resp.text
197
198
199 async def test_collaborators_settings_json_response_empty(
200 client: AsyncClient,
201 db_session: AsyncSession,
202 auth_headers: dict[str, str],
203 ) -> None:
204 """?format=json returns CollaboratorListResponse with empty list for a new repo."""
205 await _make_repo(db_session)
206 resp = await client.get(
207 f"/{_OWNER}/{_SLUG}/settings/collaborators?format=json"
208 )
209 assert resp.status_code == 200
210 data = resp.json()
211 assert "collaborators" in data
212 assert "total" in data
213 assert data["total"] == 0
214 assert data["collaborators"] == []
215
216
217 async def test_collaborators_settings_json_response_with_collaborators(
218 client: AsyncClient,
219 db_session: AsyncSession,
220 auth_headers: dict[str, str],
221 ) -> None:
222 """?format=json returns collaborators seeded in the DB."""
223 repo_id = await _make_repo(db_session)
224 collab_uid = secrets.token_hex(16)
225 await _add_collaborator(
226 db_session, repo_id, user_id=collab_uid, permission="write", invited_by="owner-user-id"
227 )
228
229 resp = await client.get(
230 f"/{_OWNER}/{_SLUG}/settings/collaborators?format=json"
231 )
232 assert resp.status_code == 200
233 data = resp.json()
234 assert data["total"] == 1
235 assert len(data["collaborators"]) == 1
236 collab = data["collaborators"][0]
237 # camelCase keys (Pydantic by_alias=True via negotiate_response)
238 assert collab["handle"] == collab_uid
239 assert collab["permission"] == "write"
240
241
242 async def test_collaborators_settings_page_has_settings_tabs(
243 client: AsyncClient,
244 db_session: AsyncSession,
245 auth_headers: dict[str, str],
246 ) -> None:
247 """The page includes the settings tab navigation bar."""
248 await _make_repo(db_session)
249 resp = await client.get(f"/{_OWNER}/{_SLUG}/settings/collaborators")
250 assert resp.status_code == 200
251 body = resp.text
252 assert "settings-tabs" in body
253 assert "Collaborators" in body
254
255
256 async def test_collaborators_settings_page_has_invite_form_fields(
257 client: AsyncClient,
258 db_session: AsyncSession,
259 auth_headers: dict[str, str],
260 ) -> None:
261 """The invite form has user_id and permission input fields rendered server-side."""
262 await _make_repo(db_session)
263 resp = await client.get(f"/{_OWNER}/{_SLUG}/settings/collaborators")
264 assert resp.status_code == 200
265 body = resp.text
266 assert 'name="user_id"' in body
267 assert 'name="permission"' in body
File History 1 commit
sha256:dc28fb2384d12a52d4b4fea7743873940b89d9d08ce298f96d0fdc8d694724d4 test+types: green the musehub suite and ratchet typing audi… Opus 4.8 minor 14 hours ago