test_musehub_auth.py
python
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