gabriel / musehub public
test_ssr_visibility.py python
194 lines 7.3 KB
Raw
sha256:f0ce2f5b7a3316126a89512a9f2ab9e4ac3cf2dbd7dae9155812a102138d15b4 test: add AV_19-AV_26 SSR visibility gate tests; fix settin… Sonnet 4.6 patch 20 hours ago
1 """SSR visibility enforcement — AV_19 through AV_26.
2
3 Verifies that the visibility gate added to _resolve_repo / _resolve_repo_full
4 in _ui_helpers.py correctly enforces private repo access, and that the
5 owner-only gate on settings returns the right status codes.
6
7 Test matrix:
8 - AV_19: Anonymous GET on private repo home page → 404
9 - AV_20: Anonymous GET on private repo commits page → 404
10 - AV_21: Anonymous GET on private repo issues page → 404
11 - AV_22: Anonymous GET on public repo home page → 200
12 - AV_23: Authenticated owner GET on private repo → 200
13 - AV_24: Unauthenticated GET on /{owner}/{slug}/settings → 401
14 - AV_25: Authenticated non-owner GET on /{owner}/{slug}/settings → 403
15 - AV_26: Authenticated owner GET on /{owner}/{slug}/settings → 200
16 """
17 from __future__ import annotations
18
19 from datetime import datetime, timezone
20 from typing import Generator
21
22 import pytest
23 from httpx import AsyncClient
24 from sqlalchemy.ext.asyncio import AsyncSession
25
26 from musehub.core.genesis import compute_identity_id, compute_repo_id
27 from musehub.db.musehub_identity_models import MusehubIdentity
28 from musehub.db.musehub_repo_models import MusehubRepo
29
30 # Matches the handle injected by the auth_headers fixture from conftest.py.
31 _OWNER = "testuser"
32
33
34 async def _make_repo(
35 db: AsyncSession,
36 owner: str,
37 slug: str,
38 visibility: str = "private",
39 ) -> MusehubRepo:
40 owner_id = compute_identity_id(owner.encode())
41 created_at = datetime.now(tz=timezone.utc)
42 repo = MusehubRepo(
43 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
44 name=slug,
45 owner=owner,
46 slug=slug,
47 visibility=visibility,
48 owner_user_id=owner_id,
49 created_at=created_at,
50 updated_at=created_at,
51 )
52 db.add(repo)
53 await db.commit()
54 await db.refresh(repo)
55 return repo
56
57
58 # ---------------------------------------------------------------------------
59 # AV_19 — Anonymous GET on private repo home → 404
60 # ---------------------------------------------------------------------------
61
62 async def test_av_19_anon_private_repo_home_returns_404(
63 client: AsyncClient,
64 db_session: AsyncSession,
65 ) -> None:
66 """AV_19: Anonymous GET on a private repo's home page returns 404."""
67 repo = await _make_repo(db_session, owner="av19owner", slug="av19-private-repo")
68 resp = await client.get(f"/{repo.owner}/{repo.slug}")
69 assert resp.status_code == 404
70
71
72 # ---------------------------------------------------------------------------
73 # AV_20 — Anonymous GET on private repo commits → 404
74 # ---------------------------------------------------------------------------
75
76 async def test_av_20_anon_private_repo_commits_returns_404(
77 client: AsyncClient,
78 db_session: AsyncSession,
79 ) -> None:
80 """AV_20: Anonymous GET on a private repo's commits page returns 404."""
81 repo = await _make_repo(db_session, owner="av20owner", slug="av20-private-repo")
82 resp = await client.get(f"/{repo.owner}/{repo.slug}/commits")
83 assert resp.status_code == 404
84
85
86 # ---------------------------------------------------------------------------
87 # AV_21 — Anonymous GET on private repo issues → 404
88 # ---------------------------------------------------------------------------
89
90 async def test_av_21_anon_private_repo_issues_returns_404(
91 client: AsyncClient,
92 db_session: AsyncSession,
93 ) -> None:
94 """AV_21: Anonymous GET on a private repo's issues page returns 404."""
95 repo = await _make_repo(db_session, owner="av21owner", slug="av21-private-repo")
96 resp = await client.get(f"/{repo.owner}/{repo.slug}/issues")
97 assert resp.status_code == 404
98
99
100 # ---------------------------------------------------------------------------
101 # AV_22 — Anonymous GET on public repo home → 200
102 # ---------------------------------------------------------------------------
103
104 async def test_av_22_anon_public_repo_home_returns_200(
105 client: AsyncClient,
106 db_session: AsyncSession,
107 ) -> None:
108 """AV_22: Anonymous GET on a public repo's home page returns 200."""
109 repo = await _make_repo(
110 db_session, owner="av22owner", slug="av22-public-repo", visibility="public"
111 )
112 resp = await client.get(f"/{repo.owner}/{repo.slug}")
113 assert resp.status_code == 200
114
115
116 # ---------------------------------------------------------------------------
117 # AV_23 — Authenticated owner GET on private repo → 200
118 # ---------------------------------------------------------------------------
119
120 async def test_av_23_authed_owner_private_repo_returns_200(
121 client: AsyncClient,
122 db_session: AsyncSession,
123 test_user: MusehubIdentity,
124 auth_headers: dict[str, str],
125 ) -> None:
126 """AV_23: Authenticated GET (valid token, owner handle) on a private repo returns 200.
127
128 auth_headers overrides optional_signed_request to return a MSignContext
129 with handle='testuser', so the visibility gate passes for a repo owned by
130 'testuser'.
131 """
132 repo = await _make_repo(db_session, owner=_OWNER, slug="av23-private-repo")
133 resp = await client.get(f"/{repo.owner}/{repo.slug}")
134 assert resp.status_code == 200
135
136
137 # ---------------------------------------------------------------------------
138 # AV_24 — Unauthenticated GET on settings → 401
139 # ---------------------------------------------------------------------------
140
141 async def test_av_24_anon_settings_returns_401(
142 client: AsyncClient,
143 db_session: AsyncSession,
144 ) -> None:
145 """AV_24: Unauthenticated GET on /{owner}/{slug}/settings returns 401.
146
147 The settings page uses require_valid_token (not optional_token), so the
148 absence of a valid MSign token must return 401 before any repo lookup.
149 """
150 repo = await _make_repo(
151 db_session, owner="av24owner", slug="av24-repo", visibility="public"
152 )
153 resp = await client.get(f"/{repo.owner}/{repo.slug}/settings")
154 assert resp.status_code == 401
155
156
157 # ---------------------------------------------------------------------------
158 # AV_25 — Authenticated non-owner GET on settings → 403
159 # ---------------------------------------------------------------------------
160
161 async def test_av_25_authed_nonowner_settings_returns_403(
162 client: AsyncClient,
163 db_session: AsyncSession,
164 test_user: MusehubIdentity,
165 auth_headers: dict[str, str],
166 ) -> None:
167 """AV_25: Authenticated non-owner GET on /{owner}/{slug}/settings returns 403.
168
169 auth_headers injects handle='testuser'. The repo owner is a different
170 handle, so the owner check (claims.handle != owner) fires 403.
171 """
172 repo = await _make_repo(
173 db_session, owner="av25-different-owner", slug="av25-repo", visibility="public"
174 )
175 resp = await client.get(f"/{repo.owner}/{repo.slug}/settings")
176 assert resp.status_code == 403
177
178
179 # ---------------------------------------------------------------------------
180 # AV_26 — Authenticated owner GET on settings → 200
181 # ---------------------------------------------------------------------------
182
183 async def test_av_26_authed_owner_settings_returns_200(
184 client: AsyncClient,
185 db_session: AsyncSession,
186 test_user: MusehubIdentity,
187 auth_headers: dict[str, str],
188 ) -> None:
189 """AV_26: Authenticated owner GET on /{owner}/{slug}/settings returns 200."""
190 repo = await _make_repo(
191 db_session, owner=_OWNER, slug="av26-repo", visibility="public"
192 )
193 resp = await client.get(f"/{repo.owner}/{repo.slug}/settings")
194 assert resp.status_code == 200
File History 1 commit
sha256:f0ce2f5b7a3316126a89512a9f2ab9e4ac3cf2dbd7dae9155812a102138d15b4 test: add AV_19-AV_26 SSR visibility gate tests; fix settin… Sonnet 4.6 patch 20 hours ago