gabriel / musehub public
test_musehub_auth.py python
163 lines 6.4 KB
Raw
sha256:5667a3e21bf16fd2e6d6bd4a769bd1c0cf7634afa12cef6450cc77573196b7f9 asyncpg caps query parameters Human patch 8 days ago
1 """Auth guard tests for MuseHub routes.
2
3 Auth model (updated in Phase 0–4 UX overhaul):
4 - GET endpoints use ``optional_token`` — public repos are accessible
5 unauthenticated; private repos return 401.
6 - POST / DELETE / write endpoints always use ``require_valid_token``.
7 - Non-existent repos return 404 regardless of auth status (no auth
8 pre-filter that exposes 401 before a DB lookup for GET routes).
9
10 Covers:
11 - Write endpoints (POST/DELETE) always return 401 without a token.
12 - GET endpoints return 404 (not 401) for non-existent repos without a token,
13 because the auth check is deferred to the visibility guard.
14 - GET endpoints return 401 for real private repos without a token.
15 - Valid tokens are accepted on write endpoints.
16 """
17 from __future__ import annotations
18
19 import pytest
20 from httpx import AsyncClient
21 from sqlalchemy.ext.asyncio import AsyncSession
22 from musehub.types.json_types import StrDict
23
24
25 # ---------------------------------------------------------------------------
26 # Write endpoints — always require auth (401 without token)
27 # Parametrized: eliminates five near-identical test functions.
28 # ---------------------------------------------------------------------------
29
30 @pytest.mark.parametrize("method,url,body", [
31 # POST endpoints that require auth regardless of whether the repo exists
32 ("POST", "/api/repos", {"name": "beats", "owner": "testuser"}),
33 ("POST", "/api/repos/any-repo-id/issues", {"title": "Bug report"}),
34 ("POST", "/api/repos/any-repo-id/issues/1/close", {}),
35 ])
36 async def test_write_endpoints_require_auth(
37 client: AsyncClient,
38 method: str,
39 url: str,
40 body: JSONObject,
41 ) -> None:
42 """Write endpoints return 401 when no MSign Authorization header is supplied."""
43 fn = getattr(client, method.lower())
44 response = await fn(url, json=body)
45 assert response.status_code == 401, (
46 f"{method} {url} expected 401, got {response.status_code}: {response.text[:200]}"
47 )
48
49
50 async def test_delete_webhook_requires_auth(client: AsyncClient, db_session: AsyncSession) -> None:
51 """DELETE /webhooks/{id} returns 401 without a token."""
52 response = await client.delete("/api/repos/any-repo-id/webhooks/fake-hook-id")
53 assert response.status_code == 401
54
55
56 # ---------------------------------------------------------------------------
57 # GET endpoints — non-existent repos return 404 (not 401) without token
58 #
59 # Rationale: optional_token + visibility guard — unauthenticated requests
60 # reach the DB; a non-existent repo returns 404 before the auth check fires.
61 # ---------------------------------------------------------------------------
62
63 @pytest.mark.parametrize("url", [
64 "/api/repos/non-existent-repo-id",
65 "/api/repos/non-existent-repo-id/branches",
66 "/api/repos/non-existent-repo-id/commits",
67 "/api/repos/non-existent-repo-id/issues",
68 "/api/repos/non-existent-repo-id/issues/1",
69 "/api/repos/non-existent-repo-id/pulls",
70 "/api/repos/non-existent-repo-id/releases",
71 ])
72 async def test_get_nonexistent_repo_returns_404_without_auth(
73 client: AsyncClient,
74 url: str,
75 ) -> None:
76 """GET on a non-existent resource returns 404 without auth (not 401).
77
78 The DB lookup happens before the visibility guard fires, so a missing
79 repo surfaces as 404 regardless of authentication status.
80 """
81 response = await client.get(url)
82 assert response.status_code == 404, (
83 f"GET {url} expected 404, got {response.status_code}"
84 )
85
86
87 # ---------------------------------------------------------------------------
88 # Private repo visibility — GET returns 401 for private repos without token
89 # ---------------------------------------------------------------------------
90
91 async def test_private_repo_returns_401_without_auth(
92 client: AsyncClient,
93 auth_headers: StrDict,
94 ) -> None:
95 """GET /repos/{id} returns 401 for a private repo without a token."""
96 from musehub.auth.request_signing import optional_signed_request, require_signed_request
97 from musehub.main import app as _app
98
99 create_resp = await client.post(
100 "/api/repos",
101 json={"name": "private-auth-test", "owner": "authtest", "visibility": "private"},
102 headers=auth_headers,
103 )
104 assert create_resp.status_code == 201
105 repo_id = create_resp.json()["repoId"]
106
107 # Temporarily remove auth overrides to simulate unauthenticated request
108 _app.dependency_overrides.pop(require_signed_request, None)
109 _app.dependency_overrides.pop(optional_signed_request, None)
110 unauth_resp = await client.get(f"/api/repos/{repo_id}")
111 assert unauth_resp.status_code == 401, (
112 f"Expected 401 for private repo, got {unauth_resp.status_code}"
113 )
114
115
116 async def test_public_repo_accessible_without_auth(
117 client: AsyncClient,
118 auth_headers: StrDict,
119 ) -> None:
120 """GET /repos/{id} returns 200 for a public repo without a token."""
121 from musehub.auth.request_signing import optional_signed_request, require_signed_request
122 from musehub.main import app as _app
123
124 create_resp = await client.post(
125 "/api/repos",
126 json={"name": "public-auth-test", "owner": "authtest", "visibility": "public"},
127 headers=auth_headers,
128 )
129 assert create_resp.status_code == 201
130 repo_id = create_resp.json()["repoId"]
131
132 # Temporarily remove auth overrides to simulate unauthenticated request
133 _app.dependency_overrides.pop(require_signed_request, None)
134 _app.dependency_overrides.pop(optional_signed_request, None)
135 unauth_resp = await client.get(f"/api/repos/{repo_id}")
136 assert unauth_resp.status_code == 200, (
137 f"Expected 200 for public repo, got {unauth_resp.status_code}: {unauth_resp.text}"
138 )
139 # Body should contain the repo data
140 body = unauth_resp.json()
141 assert body["repoId"] == repo_id
142 assert body["visibility"] == "public"
143
144
145 # ---------------------------------------------------------------------------
146 # Authenticated requests are accepted
147 # ---------------------------------------------------------------------------
148
149 async def test_hub_routes_accept_valid_token(
150 client: AsyncClient,
151 auth_headers: StrDict,
152 ) -> None:
153 """POST /musehub/repos succeeds (201) with a valid MSign auth header."""
154 response = await client.post(
155 "/api/repos",
156 json={"name": "auth-sanity-repo", "owner": "testuser"},
157 headers=auth_headers,
158 )
159 assert response.status_code == 201
160 body = response.json()
161 assert body["name"] == "auth-sanity-repo"
162 assert body["owner"] == "testuser"
163 assert "repoId" in body
File History 1 commit
sha256:5667a3e21bf16fd2e6d6bd4a769bd1c0cf7634afa12cef6450cc77573196b7f9 asyncpg caps query parameters Human patch 8 days ago