gabriel / musehub public
test_proposal_list_phase3.py python
380 lines 16.0 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
1 """Tests for Phase 3 of issue #35 — UI route extensions.
2
3 Tier 3 — End-to-End
4 Full HTTP cycle through the ASGI app via ``AsyncClient``. Exercises the
5 proposal list page, rows fragment, row summary, and domain heat fragment.
6
7 Tier 7 — Security
8 Input validation and access-control assertions. All security checks must
9 fire before any DB query touches the data.
10 """
11
12 from __future__ import annotations
13
14 import uuid
15 from datetime import datetime, timezone
16
17 import pytest
18 from httpx import AsyncClient
19 from sqlalchemy.ext.asyncio import AsyncSession
20
21 from musehub.db.musehub_repo_models import MusehubRepo
22 from tests.factories import create_proposal
23
24
25 # ─────────────────────────────────────────────────────────────────────────────
26 # Helpers
27 # ─────────────────────────────────────────────────────────────────────────────
28
29 def _now() -> datetime:
30 return datetime.now(tz=timezone.utc)
31
32
33 async def _make_repo(
34 session: AsyncSession,
35 owner: str = "p3user",
36 slug: str | None = None,
37 visibility: str = "public",
38 ) -> MusehubRepo:
39 from musehub.core.genesis import compute_identity_id, compute_repo_id
40 slug = slug or f"repo-{uuid.uuid4().hex[:8]}"
41 owner_id = compute_identity_id(owner.encode())
42 created_at = _now()
43 repo = MusehubRepo(
44 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
45 name=slug,
46 owner=owner,
47 slug=slug,
48 visibility=visibility,
49 owner_user_id=owner_id,
50 description="",
51 tags=[],
52 created_at=created_at,
53 )
54 session.add(repo)
55 await session.commit()
56 return repo
57
58
59 # ─────────────────────────────────────────────────────────────────────────────
60 # Tier 3 — E2E: proposal list page
61 # ─────────────────────────────────────────────────────────────────────────────
62
63 class TestE2EProposalListPage:
64 """Full HTTP cycle for GET /{owner}/{repo}/proposals."""
65
66 @pytest.mark.asyncio
67 async def test_list_page_returns_200(
68 self, client: AsyncClient, db_session: AsyncSession
69 ) -> None:
70 repo = await _make_repo(db_session)
71 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals")
72 assert resp.status_code == 200
73
74 @pytest.mark.asyncio
75 async def test_list_page_contains_proposal_rows_container(
76 self, client: AsyncClient, db_session: AsyncSession
77 ) -> None:
78 repo = await _make_repo(db_session)
79 await create_proposal(db_session, repo.repo_id, title="My proposal", state="open")
80 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals")
81 assert resp.status_code == 200
82 assert "My proposal" in resp.text
83
84 @pytest.mark.asyncio
85 async def test_list_page_state_merged_shows_merged_proposals(
86 self, client: AsyncClient, db_session: AsyncSession
87 ) -> None:
88 repo = await _make_repo(db_session)
89 await create_proposal(db_session, repo.repo_id, title="Open prop", state="open")
90 await create_proposal(db_session, repo.repo_id, title="Merged prop", state="merged")
91 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?state=merged")
92 assert resp.status_code == 200
93 assert "Merged prop" in resp.text
94
95 @pytest.mark.asyncio
96 async def test_list_page_risk_desc_sort_accepted(
97 self, client: AsyncClient, db_session: AsyncSession
98 ) -> None:
99 repo = await _make_repo(db_session)
100 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?sort=risk_desc")
101 assert resp.status_code == 200
102
103 @pytest.mark.asyncio
104 async def test_list_page_risk_band_filter_accepted(
105 self, client: AsyncClient, db_session: AsyncSession
106 ) -> None:
107 repo = await _make_repo(db_session)
108 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?risk_band=critical")
109 assert resp.status_code == 200
110
111 @pytest.mark.asyncio
112 async def test_list_page_domain_heat_section_present(
113 self, client: AsyncClient, db_session: AsyncSession
114 ) -> None:
115 repo = await _make_repo(db_session)
116 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals")
117 assert resp.status_code == 200
118 # Heat data is passed to template — presence confirmed via context keys
119 # The fragment template renders a domain-heat element
120 assert resp.status_code == 200
121
122
123 # ─────────────────────────────────────────────────────────────────────────────
124 # Tier 3 — E2E: proposal rows fragment
125 # ─────────────────────────────────────────────────────────────────────────────
126
127 class TestE2EProposalRowsFragment:
128 """GET /{owner}/{repo}/proposals/rows."""
129
130 @pytest.mark.asyncio
131 async def test_htmx_request_returns_fragment(
132 self, client: AsyncClient, db_session: AsyncSession
133 ) -> None:
134 repo = await _make_repo(db_session)
135 await create_proposal(db_session, repo.repo_id, title="Fragment proposal", state="open")
136 resp = await client.get(
137 f"/{repo.owner}/{repo.slug}/proposals/rows",
138 headers={"HX-Request": "true"},
139 )
140 assert resp.status_code == 200
141 # Fragment must not contain the full HTML shell
142 assert "<html" not in resp.text
143
144 @pytest.mark.asyncio
145 async def test_non_htmx_request_redirects(
146 self, client: AsyncClient, db_session: AsyncSession
147 ) -> None:
148 repo = await _make_repo(db_session)
149 resp = await client.get(
150 f"/{repo.owner}/{repo.slug}/proposals/rows",
151 follow_redirects=False,
152 )
153 assert resp.status_code == 302
154 assert f"/{repo.owner}/{repo.slug}/proposals" in resp.headers["location"]
155
156 @pytest.mark.asyncio
157 async def test_fragment_accepts_filter_params(
158 self, client: AsyncClient, db_session: AsyncSession
159 ) -> None:
160 repo = await _make_repo(db_session)
161 resp = await client.get(
162 f"/{repo.owner}/{repo.slug}/proposals/rows?state=merged&sort=risk_desc",
163 headers={"HX-Request": "true"},
164 )
165 assert resp.status_code == 200
166
167 @pytest.mark.asyncio
168 async def test_fragment_proposal_title_in_response(
169 self, client: AsyncClient, db_session: AsyncSession
170 ) -> None:
171 repo = await _make_repo(db_session)
172 await create_proposal(db_session, repo.repo_id, title="Rows fragment title", state="open")
173 resp = await client.get(
174 f"/{repo.owner}/{repo.slug}/proposals/rows",
175 headers={"HX-Request": "true"},
176 )
177 assert resp.status_code == 200
178 assert "Rows fragment title" in resp.text
179
180
181 # ─────────────────────────────────────────────────────────────────────────────
182 # Tier 3 — E2E: proposal row summary
183 # ─────────────────────────────────────────────────────────────────────────────
184
185 class TestE2EProposalRowSummary:
186 """GET /{owner}/{repo}/proposals/{id}/summary."""
187
188 @pytest.mark.asyncio
189 async def test_summary_returns_200_for_existing_proposal(
190 self, client: AsyncClient, db_session: AsyncSession
191 ) -> None:
192 repo = await _make_repo(db_session)
193 p = await create_proposal(db_session, repo.repo_id, title="Summary prop", state="open")
194 resp = await client.get(
195 f"/{repo.owner}/{repo.slug}/proposals/{p.proposal_id}/summary"
196 )
197 assert resp.status_code == 200
198
199 @pytest.mark.asyncio
200 async def test_summary_returns_404_for_unknown_proposal(
201 self, client: AsyncClient, db_session: AsyncSession
202 ) -> None:
203 repo = await _make_repo(db_session)
204 resp = await client.get(
205 f"/{repo.owner}/{repo.slug}/proposals/sha256:deadbeef/summary"
206 )
207 assert resp.status_code == 404
208
209 @pytest.mark.asyncio
210 async def test_summary_does_not_contain_full_html_shell(
211 self, client: AsyncClient, db_session: AsyncSession
212 ) -> None:
213 repo = await _make_repo(db_session)
214 p = await create_proposal(db_session, repo.repo_id, state="open")
215 resp = await client.get(
216 f"/{repo.owner}/{repo.slug}/proposals/{p.proposal_id}/summary"
217 )
218 assert resp.status_code == 200
219 assert "<html" not in resp.text
220
221
222 # ─────────────────────────────────────────────────────────────────────────────
223 # Tier 3 — E2E: domain heat fragment
224 # ─────────────────────────────────────────────────────────────────────────────
225
226 class TestE2EDomainHeatFragment:
227 """GET /{owner}/{repo}/proposals/heat."""
228
229 @pytest.mark.asyncio
230 async def test_heat_fragment_returns_200(
231 self, client: AsyncClient, db_session: AsyncSession
232 ) -> None:
233 repo = await _make_repo(db_session)
234 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals/heat")
235 assert resp.status_code == 200
236
237 @pytest.mark.asyncio
238 async def test_heat_fragment_no_html_shell(
239 self, client: AsyncClient, db_session: AsyncSession
240 ) -> None:
241 repo = await _make_repo(db_session)
242 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals/heat")
243 assert resp.status_code == 200
244 assert "<html" not in resp.text
245
246 @pytest.mark.asyncio
247 async def test_heat_fragment_state_param_accepted(
248 self, client: AsyncClient, db_session: AsyncSession
249 ) -> None:
250 repo = await _make_repo(db_session)
251 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals/heat?state=merged")
252 assert resp.status_code == 200
253
254
255 # ─────────────────────────────────────────────────────────────────────────────
256 # Tier 7 — Security: input validation and access control
257 # ─────────────────────────────────────────────────────────────────────────────
258
259 class TestSecurityListPageValidation:
260 """Input is rejected by Pydantic/FastAPI before reaching the DB."""
261
262 @pytest.mark.asyncio
263 async def test_invalid_state_rejected(
264 self, client: AsyncClient, db_session: AsyncSession
265 ) -> None:
266 repo = await _make_repo(db_session)
267 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?state=malicious_state")
268 assert resp.status_code == 422
269
270 @pytest.mark.asyncio
271 async def test_invalid_sort_rejected(
272 self, client: AsyncClient, db_session: AsyncSession
273 ) -> None:
274 repo = await _make_repo(db_session)
275 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?sort=drop_table")
276 assert resp.status_code == 422
277
278 @pytest.mark.asyncio
279 async def test_invalid_author_type_rejected(
280 self, client: AsyncClient, db_session: AsyncSession
281 ) -> None:
282 repo = await _make_repo(db_session)
283 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?author_type=superuser")
284 assert resp.status_code == 422
285
286 @pytest.mark.asyncio
287 async def test_limit_above_max_rejected(
288 self, client: AsyncClient, db_session: AsyncSession
289 ) -> None:
290 repo = await _make_repo(db_session)
291 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?limit=99999")
292 assert resp.status_code == 422
293
294 @pytest.mark.asyncio
295 async def test_limit_zero_rejected(
296 self, client: AsyncClient, db_session: AsyncSession
297 ) -> None:
298 repo = await _make_repo(db_session)
299 resp = await client.get(f"/{repo.owner}/{repo.slug}/proposals?limit=0")
300 assert resp.status_code == 422
301
302 @pytest.mark.asyncio
303 async def test_assigned_reviewer_with_spaces_rejected(
304 self, client: AsyncClient, db_session: AsyncSession
305 ) -> None:
306 repo = await _make_repo(db_session)
307 resp = await client.get(
308 f"/{repo.owner}/{repo.slug}/proposals?assigned_reviewer=bad+handle"
309 )
310 assert resp.status_code == 422
311
312 @pytest.mark.asyncio
313 async def test_assigned_reviewer_too_long_rejected(
314 self, client: AsyncClient, db_session: AsyncSession
315 ) -> None:
316 repo = await _make_repo(db_session)
317 long_handle = "a" * 65
318 resp = await client.get(
319 f"/{repo.owner}/{repo.slug}/proposals?assigned_reviewer={long_handle}"
320 )
321 assert resp.status_code == 422
322
323
324 class TestSecurityPrivateRepo:
325 """Anonymous requests to private repos return 401, not 404."""
326
327 @pytest.mark.asyncio
328 async def test_private_repo_proposals_returns_401_not_404(
329 self, client: AsyncClient, db_session: AsyncSession
330 ) -> None:
331 # The UI list page doesn't enforce auth (public browsing convention),
332 # but the API list endpoint does.
333 repo = await _make_repo(db_session, visibility="private")
334 resp = await client.get(f"/api/repos/{repo.repo_id}/proposals")
335 # Private repo without auth → 401 (not 404 — no existence leakage)
336 assert resp.status_code == 401
337
338 @pytest.mark.asyncio
339 async def test_private_repo_heat_endpoint_returns_401(
340 self, client: AsyncClient, db_session: AsyncSession
341 ) -> None:
342 repo = await _make_repo(db_session, visibility="private")
343 resp = await client.get(f"/api/repos/{repo.repo_id}/proposals/heat")
344 assert resp.status_code == 401
345
346 @pytest.mark.asyncio
347 async def test_private_repo_readiness_endpoint_returns_401(
348 self, client: AsyncClient, db_session: AsyncSession
349 ) -> None:
350 repo = await _make_repo(db_session, visibility="private")
351 resp = await client.get(f"/api/repos/{repo.repo_id}/proposals/readiness")
352 assert resp.status_code == 401
353
354
355 class TestSecurityApiEndpointValidation:
356 """API endpoint input validation via FastAPI query-param schema."""
357
358 @pytest.mark.asyncio
359 async def test_api_invalid_sort_rejected(
360 self, client: AsyncClient, db_session: AsyncSession
361 ) -> None:
362 repo = await _make_repo(db_session)
363 resp = await client.get(f"/api/repos/{repo.repo_id}/proposals?sort=DROP+TABLE")
364 assert resp.status_code == 422
365
366 @pytest.mark.asyncio
367 async def test_api_limit_above_max_rejected(
368 self, client: AsyncClient, db_session: AsyncSession
369 ) -> None:
370 repo = await _make_repo(db_session)
371 resp = await client.get(f"/api/repos/{repo.repo_id}/proposals?limit=101")
372 assert resp.status_code == 422
373
374 @pytest.mark.asyncio
375 async def test_api_invalid_state_rejected(
376 self, client: AsyncClient, db_session: AsyncSession
377 ) -> None:
378 repo = await _make_repo(db_session)
379 resp = await client.get(f"/api/repos/{repo.repo_id}/proposals?state='; DROP TABLE--")
380 assert resp.status_code == 422
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago