gabriel / musehub public
test_musehub_ui_team.py python
267 lines 9.2 KB
Raw
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days 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 13 commits
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 12 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 34 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago