Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions app/github/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,39 @@
@bp.route("/")
@login_required
def index():
"""Dashboard showing the user's GitHub connection status."""
"""Dashboard showing the user's GitHub connection status.

Displays the scopes GitHub actually *granted* (issue #57), falling back to
the scopes captured at connect time, and warns when the ``repo`` scope is
missing (private repositories will not load).
"""
account = GithubAccount.query.filter_by(user_id=current_user.id).first()
return render_template("github/index.html", account=account)
granted_scopes = _granted_scopes(account)
repo_scope_missing = bool(granted_scopes) and "repo" not in granted_scopes
return render_template(
"github/index.html",
account=account,
granted_scopes=granted_scopes,
repo_scope_missing=repo_scope_missing,
)


def _granted_scopes(account: GithubAccount | None) -> list[str]:
"""Return the scopes to display for the dashboard.

Prefers a live ``GET /user`` read (the token's current grants) and falls
back to the scopes stored at connect time. Best-effort: a failed read never
breaks the dashboard, and the value is used for display only — never for an
authorization decision (issue #57).
"""
if account is None:
return []
stored = [scope.strip() for scope in (account.scopes or "").split(",") if scope.strip()]
try:
live = _client().get_granted_scopes()
except GitHubError:
live = []
return live or stored


@bp.route("/connect")
Expand Down Expand Up @@ -154,7 +184,13 @@ def callback():
db.session.add(account)
account.github_user_id = user["id"]
account.github_username = user.get("login", "")
account.scopes = token_data.get("scope", "")
# Prefer the scopes GitHub reports via the X-OAuth-Scopes header, falling
# back to the token-exchange `scope` value. Informational only (issue #57).
try:
granted = client.get_granted_scopes()
except GitHubError:
granted = []
account.scopes = ",".join(granted) if granted else token_data.get("scope", "")
account.token_type = token_data.get("token_type", "bearer")
account.set_access_token(token)
account.set_refresh_token(token_data.get("refresh_token"))
Expand Down Expand Up @@ -216,6 +252,8 @@ def status():
}
if account is not None:
payload["rate_limit"] = _rate_limit_summary()
scopes = [scope.strip() for scope in (account.scopes or "").split(",") if scope.strip()]
payload["repo_scope_missing"] = bool(scopes) and "repo" not in scopes
return jsonify(payload)


Expand Down
17 changes: 17 additions & 0 deletions app/services/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,23 @@ def _get_page(
def get_user(self) -> dict:
return self._get("/user")

def get_granted_scopes(self) -> list[str]:
"""Return the OAuth scopes GitHub actually granted this token (issue #57).

GitHub reports the granted scopes in the ``X-OAuth-Scopes`` response
header of an authenticated request (the token exchange response also
carries them, but the header reflects the token's *current* grants).
The result is informational only: it is never used to make
authorization decisions, which always depend on the token itself.
"""
response = self.session.get(f"{self.api_url}/user", timeout=self.timeout)
if response.status_code >= 400:
# Re-issue through _request so failures become the typed errors the
# rest of the app expects (auth, rate limit, not found, ...).
self._request("GET", "/user")
raw = response.headers.get("X-OAuth-Scopes", "")
return [scope.strip() for scope in raw.split(",") if scope.strip()]

def get_rate_limit(self) -> dict | None:
"""Return the caller's core rate-limit budget, or ``None`` if unknown.

Expand Down
14 changes: 13 additions & 1 deletion app/templates/github/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ <h1>GitHub Integration</h1>
<div class="github-connection-status">
<span class="status-dot status-connected"></span>
Connected as <strong>@{{ account.github_username }}</strong>
{% if account.scopes %}<span class="github-scopes">(scopes: {{ account.scopes }})</span>{% endif %}
{% if granted_scopes %}
<span class="github-scopes">(granted scopes: {{ granted_scopes|join(', ') }})</span>
{% else %}
<span class="github-scopes">(granted scopes not reported by GitHub)</span>
{% endif %}
</div>
<div class="github-connection-actions">
<a class="btn btn-primary" href="{{ url_for('github.repos') }}">Browse repositories</a>
Expand All @@ -23,6 +27,14 @@ <h1>GitHub Integration</h1>
</div>
</div>

{% if repo_scope_missing %}
<div class="github-warning" role="alert">
<strong>Missing the <code>repo</code> scope.</strong>
Private repositories will not load until you reconnect and grant it.
<a href="{{ url_for('github.connect') }}">Reconnect GitHub</a>.
</div>
{% endif %}

<div id="github-rate-limit" class="github-rate-limit" role="status" aria-live="polite" hidden></div>

<div class="github-features">
Expand Down
94 changes: 94 additions & 0 deletions tests/test_github_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -754,3 +754,97 @@ def test_token_never_serialized(self, client, app, monkeypatch):
def _last_session_state(client):
with client.session_transaction() as sess:
return sess.get("github_oauth_state")


class TestDashboardGrantedScopes:
"""The dashboard shows the granted scopes and warns when repo is missing (#57)."""

def test_shows_granted_scopes_without_warning_when_repo_present(self, client, app, monkeypatch):
_logged_in_client(client)
_create_account(app)
monkeypatch.setattr(
"app.services.github.requests.Session",
lambda: _make_fake_session(
[
(
"GET",
"/user",
200,
{"id": 42, "login": "ghuser"},
{"X-OAuth-Scopes": "read:user, repo"},
)
]
),
)
body = client.get("/github/").get_data(as_text=True)
assert "granted scopes" in body
assert "read:user" in body
assert "repo" in body
assert "Missing the" not in body

def test_warns_when_repo_scope_missing(self, client, app, monkeypatch):
_logged_in_client(client)
_create_account(app)
monkeypatch.setattr(
"app.services.github.requests.Session",
lambda: _make_fake_session(
[
(
"GET",
"/user",
200,
{"id": 42, "login": "ghuser"},
{"X-OAuth-Scopes": "read:user, gist"},
)
]
),
)
body = client.get("/github/").get_data(as_text=True)
assert "Missing the" in body

def test_falls_back_to_stored_scopes_when_header_unavailable(self, client, app, monkeypatch):
_logged_in_client(client)
account = _create_account(app)
account.scopes = "read:user, repo"
db.session.commit()
monkeypatch.setattr(
"app.services.github.requests.Session",
lambda: _make_fake_session([("GET", "/user", 200, {"id": 42, "login": "ghuser"})]),
)
body = client.get("/github/").get_data(as_text=True)
assert "read:user, repo" in body


class TestStatusScopes:
"""The status API reports whether the repo scope is missing (issue #57)."""

_RATE_LIMIT = (
"GET",
"/rate_limit",
200,
{"resources": {"core": {"limit": 5000, "remaining": 4999, "reset": 1700000000, "used": 1}}},
)

def _patch(self, monkeypatch):
monkeypatch.setattr(
"app.services.github.requests.Session",
lambda: _make_fake_session([self._RATE_LIMIT]),
)

def test_status_flags_missing_repo_scope(self, client, app, monkeypatch):
_logged_in_client(client)
account = _create_account(app)
account.scopes = "read:user"
db.session.commit()
self._patch(monkeypatch)
data = client.get("/github/api/status").get_json()
assert data["repo_scope_missing"] is True

def test_status_repo_scope_present(self, client, app, monkeypatch):
_logged_in_client(client)
account = _create_account(app)
account.scopes = "read:user,repo"
db.session.commit()
self._patch(monkeypatch)
data = client.get("/github/api/status").get_json()
assert data["repo_scope_missing"] is False
23 changes: 23 additions & 0 deletions tests/test_github_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,3 +372,26 @@ def test_validate_path_rejects_traversal(self):
for bad in ["../secret", "foo/../../etc/passwd", "..", "a/../b"]:
with pytest.raises(GitHubError):
validate_path(bad)


class TestGrantedScopes:
"""get_granted_scopes reads the X-OAuth-Scopes header (issue #57)."""

def test_reads_and_splits_the_header(self, ok_client):
client, session = ok_client
session.responses = [
FakeResponse(200, data={"id": 1}, headers={"X-OAuth-Scopes": "read:user, repo, gist"})
]
assert client.get_granted_scopes() == ["read:user", "repo", "gist"]

def test_trims_whitespace_and_ignores_blanks(self, ok_client):
client, session = ok_client
session.responses = [
FakeResponse(200, data={"id": 1}, headers={"X-OAuth-Scopes": " repo ,, read:user "})
]
assert client.get_granted_scopes() == ["repo", "read:user"]

def test_empty_when_header_absent(self, ok_client):
client, session = ok_client
session.responses = [FakeResponse(200, data={"id": 1})]
assert client.get_granted_scopes() == []