From 681999df7e5a80a63dcf345dbcac495ab66bf070 Mon Sep 17 00:00:00 2001 From: JS Date: Sat, 10 Oct 2026 07:02:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20campus=20auth=20status=20=E2=80=94?= =?UTF-8?q?=20show=20stored=20token=20identity=20and=20scopes=20(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens carry exactly the scopes requested at login (no accumulation), but the credential store never recorded them: the only way to learn what a stored token could do was to hit a 403 and re-login. The store now records the granted scopes and authorizing principal at login (additive keys, written from the device grant response), and `campus auth status` reports them alongside the client id, endpoints and expiry — with a --json mode for scripts and agents. The default mode adds a lightweight authenticated validity check: GET /users/ with the bearer, classified by status code only (FORBIDDEN = valid-but-unprivileged, UNAUTHORIZED = rejected). The probe targets the endpoint that minted the token, mirroring logout's revoke-at-issuing-endpoint rule. --offline skips it. Network errors are a third, non-fatal state (warning, exit 0); a rejected token exits 1, as does not-logged-in. --- README.md | 23 ++ campus_cli/auth/login.py | 119 ++++------- campus_cli/auth/status.py | 304 +++++++++++++++++++++++++++ campus_cli/credentials.py | 53 +++++ tests/integration/test_cli.py | 43 +++- tests/unit/test_login.py | 65 ++++++ tests/unit/test_status.py | 380 ++++++++++++++++++++++++++++++++++ 7 files changed, 907 insertions(+), 80 deletions(-) create mode 100644 campus_cli/auth/status.py create mode 100644 tests/unit/test_status.py diff --git a/README.md b/README.md index 4e8f1f7..f779a7d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ campus auth login # unknown scopes) campus auth login --scope clients:write +# Show what the stored token can do: client, endpoint, principal, +# scopes, expiry — plus a lightweight server-side validity check +# (network errors are reported without failing; exit code 0) +campus auth status + +# Same, as JSON for scripts and agents +campus auth status --json + +# Skip the validity check entirely (no network calls) +campus auth status --offline + # Create an OAuth client campus client new --name "My App" --description "My application" @@ -78,6 +89,18 @@ The semantics are exact-request, not Google-style accumulation: `AUTH_USERS_ADMIN_USER_IDS` (a clients-admin is not a users-admin). +**Check before you re-login:** `campus auth status` reports the +scopes the stored token carries (along with the client, endpoints, +principal and expiry), so scripts and agents can answer "what can +this token do?" before hitting a 403. Use `--json` for +machine-readable output; `--offline` skips the validity check when +no network is wanted. Scopes are recorded locally at login — +credentials stored before this was recorded show them as unknown +until the next login. `campus auth status` exits 1 when you are not +logged in or the server rejects the stored token, and 0 otherwise +(including when the server is unreachable, which is reported as a +warning). + For scripts and agents: request the full scope set you will need up front, and treat a 403 `Token lacks ''` as the cue to re-run login with that scope — expecting the old token to die when diff --git a/campus_cli/auth/login.py b/campus_cli/auth/login.py index 7699e88..dad83e9 100644 --- a/campus_cli/auth/login.py +++ b/campus_cli/auth/login.py @@ -14,7 +14,6 @@ confirm_destructive, endpoint_mismatch, ensure_endpoint_match, - get_token_status, refresh_access_token, resolve_auth_url, revoke_token, @@ -419,12 +418,26 @@ def login_cmd( credentials.set_refresh_token(refresh_token) credentials.set_token_auth_url(auth_url) - # Record the login server-side (#837): the device grant - # echoes the authorizing user, and the login-session record - # carries this install's stable device id so audit spans - # attribute to the device. Best-effort — an older auth - # deployment without the logins route must not fail login. + # Record the granted scopes and authorizing principal + # locally (#45), so `campus auth status` can report them + # without a network round-trip. Purely additive: the device + # grant response carries the space-joined `scope` and the + # `user_id`; credentials stored before this simply lack the + # keys and status reports them as unknown. + scopes = token_data.get("scope") + if isinstance(scopes, str) and scopes.strip(): + credentials.set_token_scopes(scopes.split()) user_id = token_data.get("user_id") + if user_id: + credentials.set_token_user_id(user_id) + + # Record the login server-side (#837): the device grant + # echoes the authorizing user (already extracted above for + # the local principal record, #45), and the login-session + # record carries this install's stable device id so audit + # spans attribute to the device. Best-effort — an older + # auth deployment without the logins route must not fail + # login. if user_id: login_session_id = create_login_session(auth_url, user_id) if login_session_id: @@ -599,74 +612,34 @@ def status_cmd( "--json", help="Output status as JSON", ), + offline: bool = typer.Option( + False, + "--offline", + help=( + "Skip the authenticated validity check (no network calls;" + " the check verifies the stored token against GET users/," + " where FORBIDDEN means valid and UNAUTHORIZED invalid)." + " Exit codes: 0 when a stored token is present and valid —" + " or its check was skipped, unreachable, or inconclusive;" + " 1 when not logged in or the token was rejected by the" + " server." + ), + ), ) -> None: """ - Check authentication status. - - Shows whether you are currently authenticated and token expiry information. + Show the stored credential's identity and scopes (#45). + + Prints the auth client id, the endpoint in use (and the endpoint + that minted the token), the authenticated principal, the scopes + the stored token carries, and token expiry — everything scripts + need to answer "what can this token do?" before acting. Unless + --offline is given, a lightweight authenticated check also reports + whether the token still authenticates (network errors are + non-fatal: they are reported without failing the command). + + Exit code: 1 when not logged in, or when the stored token is + rejected by the server; 0 otherwise. """ - status = get_token_status() - - if output_json: - # Plain stdout write: Rich's word wrap can split long values - # mid-string and corrupt machine-readable JSON. - import json - - typer.echo(json.dumps(status)) - else: - if status["authenticated"]: - print_success("Authenticated") - console.print("You are logged in to Campus API.") - console.print(f"Auth endpoint: {status['auth_url']}") - - if not status["endpoint_match"]: - console.print( - "[red bold]Endpoint mismatch:[/red bold] the stored " - "token was issued by a different auth endpoint. " - "Run [bold]campus auth login[/bold] to re-authenticate." - ) - if status["token_auth_url"]: - console.print(f"Token issued by: {status['token_auth_url']}") - else: - console.print( - "[dim]Token issued by: unknown (stored before endpoint" - " binding; binds at next login or refresh)[/dim]" - ) - - if status["expires_at"]: - from datetime import datetime, timezone - - try: - expiry_dt = datetime.fromisoformat(status["expires_at"]) - if expiry_dt.tzinfo is None: - expiry_dt = expiry_dt.replace(tzinfo=timezone.utc) - now = datetime.now(timezone.utc) - - if status["is_expired"]: - console.print("[red bold]Token has expired.[/red bold]") - else: - remaining = expiry_dt - now - minutes = int(remaining.total_seconds() // 60) - seconds = int(remaining.total_seconds() % 60) - console.print( - f"Token expires in: [cyan]{minutes}m {seconds}s[/cyan]" - ) + from campus_cli.auth.status import run_status - console.print( - f"Expires at: {expiry_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}" - ) - except ValueError: - console.print(f"Expires at: {status['expires_at']}") - else: - console.print("[dim](No expiry information available)[/dim]") - - if status["can_refresh"]: - refresh_status = ( - "[green]enabled[/green]" if config.auto_refresh - else "[yellow]disabled[/yellow]" - ) - console.print(f"Auto-refresh: {refresh_status}") - else: - console.print("[yellow]Not authenticated[/yellow]") - console.print(f"Auth endpoint: {status['auth_url']}") - console.print("Run [bold]campus auth login[/bold] to authenticate.") + run_status(output_json=output_json, offline=offline) diff --git a/campus_cli/auth/status.py b/campus_cli/auth/status.py new file mode 100644 index 0000000..eab9667 --- /dev/null +++ b/campus_cli/auth/status.py @@ -0,0 +1,304 @@ +"""`campus auth status` — stored-token identity and scopes (#45). + +Tokens carry exactly the scopes requested at login (no accumulation), +so the scopes recorded at login describe the stored token until it is +replaced. This module assembles that locally-held identity — client, +endpoints, principal, scopes, expiry — and, unless --offline is given, +adds a lightweight authenticated validity check. + +The validity check (#45) hits GET /users/ with the bearer token +and reads only the status code, which the error envelope design keeps +meaningful: + +- 403 FORBIDDEN — the token authenticated but may not list users + (users:read is operator/admin-only): the token is valid. +- 401 UNAUTHORIZED — the token is unknown/revoked/expired: invalid. +- 200 — a users:read token that listed users: valid. + +Raw requests rather than the campus_python client: the SDK's +CampusRequest wraps requests.RequestException as errors.ServerError, +which would conflate "could not reach server" with a real HTTP 500 — +the three-state machine below needs the two apart. This also matches +the device-flow HTTP style in login.py. + +States and exit codes (documented in the command's help): + +- not logged in — friendly message, exit 1 (both modes). +- token rejected (401) — reported as invalid, exit 1. +- could not reach server (network error) — third state, NON-FATAL: + warning printed, exit 0, validity reported as "unreachable". +- any other inconclusive probe result (e.g. 5xx) — exit 0, validity + "unknown". +""" + +import json +from datetime import datetime, timezone + +import requests +import typer + +from campus_cli.auth import common +from campus_cli.auth.common import get_token_status +from campus_cli.config import PUBLIC_OAUTH_CLIENT_ID +from campus_cli.utils.output import console, print_success, print_warning + +# Validity states for the token check. "not_checked" is the --offline +# value; None (JSON null) is used when there is no token to check. +VALIDITY_NOT_CHECKED = "not_checked" +VALIDITY_VALID = "valid" +VALIDITY_INVALID = "invalid" +VALIDITY_UNREACHABLE = "unreachable" +VALIDITY_UNKNOWN = "unknown" + +# The probe is a single lightweight GET; a short timeout keeps +# `campus auth status` snappy even when the server is down. +_PROBE_TIMEOUT = 10 + + +def probe_token_validity(auth_url: str, token: str) -> tuple[str, str | None]: + """Check whether an access token still authenticates (#45). + + Sends the cheapest authenticated request the auth server offers — + GET /users/ — and classifies the response by status code alone: + FORBIDDEN means the token is valid but not allowed to list users, + UNAUTHORIZED means the token was rejected (see the module docstring + for why raw requests are used instead of the client library). + + Args: + auth_url: The auth endpoint the token is bound to. + token: The access token to check. + + Returns: + (validity, note): validity is one of VALIDITY_VALID, + VALIDITY_INVALID, VALIDITY_UNREACHABLE or VALIDITY_UNKNOWN; + note carries detail for the unreachable/unknown states and is + None otherwise. + """ + try: + response = requests.get( + f"{auth_url}/users/", + headers={"Authorization": f"Bearer {token}"}, + timeout=_PROBE_TIMEOUT, + ) + except requests.RequestException as e: + # Note carries only the underlying error; the "could not reach + # server" phrasing is supplied by whichever output renders it. + return VALIDITY_UNREACHABLE, str(e) + + if response.status_code in (200, 403): + # 403: authenticated, just lacking users:read — valid. A + # users:read token gets 200 (the user list is discarded). + return VALIDITY_VALID, None + if response.status_code == 401: + return VALIDITY_INVALID, None + return VALIDITY_UNKNOWN, f"server returned HTTP {response.status_code}" + + +def _expires_in_seconds(expires_at: str | None) -> int | None: + """Seconds until the stored expiry, negative once past. + + None when no expiry is recorded or the timestamp is unparseable + (the raw string is still shown in the human output, matching the + other auth commands' leniency). + """ + if not expires_at: + return None + try: + expiry_dt = datetime.fromisoformat(expires_at) + if expiry_dt.tzinfo is None: + expiry_dt = expiry_dt.replace(tzinfo=timezone.utc) + remaining = expiry_dt - datetime.now(timezone.utc) + return int(remaining.total_seconds()) + except (ValueError, TypeError): + return None + + +def build_status(offline: bool) -> dict: + """Assemble the full status payload from the credential store. + + Extends common.get_token_status (authenticated/expiry/endpoint + fields, kept verbatim for --json backward compatibility) with the + identity fields recorded at login (#45): client id, principal and + scopes — plus the token check result unless offline. + + Args: + offline: True skips the network validity check entirely. + + Returns: + The status dict (see the command docstring for the schema). + """ + token = get_token_status() + creds = common.credentials + authenticated: bool = token["authenticated"] + user_id = creds.get_token_user_id() if authenticated else None + scopes = creds.get_token_scopes() if authenticated else None + + status = { + **token, + "client_id": PUBLIC_OAUTH_CLIENT_ID, + "user_id": user_id, + # None means "not recorded" (credential predates #45 or no + # token), never "no scopes": an empty list is a real answer. + "scopes": scopes, + "expires_in_seconds": ( + _expires_in_seconds(token["expires_at"]) if authenticated else None + ), + } + + if not authenticated: + status.update(token_validity=None, probe_url=None, probe_note=None) + return status + + if offline: + status.update( + token_validity=VALIDITY_NOT_CHECKED, + probe_url=None, + probe_note=None, + ) + return status + + # Probe the endpoint the token is bound to — where it is actually + # valid — mirroring logout's revoke-at-issuing-endpoint rule. + # Unbound (pre-endpoint-binding) credentials fall back to the + # current target. + probe_url = token["token_auth_url"] or token["auth_url"] + validity, note = probe_token_validity(probe_url, creds.get_token()) + status.update(token_validity=validity, probe_url=probe_url, probe_note=note) + return status + + +def _print_validity_line(status: dict) -> None: + """Print the human-readable token check result.""" + validity = status["token_validity"] + if validity == VALIDITY_NOT_CHECKED: + console.print("[dim]Token check: skipped (--offline)[/dim]") + return + if validity is None: + return + + probe_url = status["probe_url"] + where = f" against {probe_url}" if probe_url else "" + if validity == VALIDITY_VALID: + console.print(f"Token check{where}: [green]valid[/green]") + elif validity == VALIDITY_INVALID: + console.print( + f"Token check{where}: [red bold]invalid[/red bold] — the server " + "rejected the token. Run [bold]campus auth login[/bold] to " + "re-authenticate." + ) + elif validity == VALIDITY_UNREACHABLE: + print_warning( + f"Token check{where}: could not reach server " + f"({status['probe_note']}) — validity unknown." + ) + else: + print_warning( + f"Token check{where}: inconclusive " + f"({status['probe_note']}) — validity unknown." + ) + + +def _print_expiry(status: dict) -> None: + """Print the expiry block, matching the other auth commands' style.""" + expires_at = status["expires_at"] + if not expires_at: + console.print("[dim](No expiry information available)[/dim]") + return + try: + expiry_dt = datetime.fromisoformat(expires_at) + if expiry_dt.tzinfo is None: + expiry_dt = expiry_dt.replace(tzinfo=timezone.utc) + if status["is_expired"]: + console.print("[red bold]Token has expired.[/red bold]") + else: + total = max( + 0, int((expiry_dt - datetime.now(timezone.utc)).total_seconds()) + ) + console.print( + f"Token expires in: [cyan]" + f"{total // 3600}h {total % 3600 // 60}m {total % 60}s" + f"[/cyan]" + ) + console.print( + f"Expires at: {expiry_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}" + ) + except ValueError: + console.print(f"Expires at: {expires_at}") + + +def _print_human(status: dict) -> None: + """Render the status payload for a terminal.""" + if not status["authenticated"]: + console.print("[yellow]Not authenticated[/yellow]") + console.print(f"Auth endpoint: {status['auth_url']}") + console.print("Run [bold]campus auth login[/bold] to authenticate.") + return + + print_success("Authenticated") + console.print(f"Client: {status['client_id']}") + console.print(f"Auth endpoint: {status['auth_url']}") + + if not status["endpoint_match"]: + console.print( + "[red bold]Endpoint mismatch:[/red bold] the stored " + "token was issued by a different auth endpoint. " + "Run [bold]campus auth login[/bold] to re-authenticate." + ) + if status["token_auth_url"]: + console.print(f"Token issued by: {status['token_auth_url']}") + else: + console.print( + "[dim]Token issued by: unknown (stored before endpoint" + " binding; binds at next login or refresh)[/dim]" + ) + + if status["user_id"]: + console.print(f"Principal: {status['user_id']}") + else: + console.print( + "[dim]Principal: unknown (not recorded; shown after next" + " login)[/dim]" + ) + + scopes = status["scopes"] + if scopes is None: + console.print( + "[dim]Scopes: unknown (not recorded; re-login to record" + " this token's scopes)[/dim]" + ) + elif scopes: + console.print(f"Scopes: {', '.join(scopes)}") + else: + console.print("Scopes: (none)") + + _print_expiry(status) + + if status["can_refresh"]: + console.print( + "[dim]Refresh token: stored (campus auth refresh" + " reissues the same scopes)[/dim]" + ) + + _print_validity_line(status) + + +def run_status(output_json: bool, offline: bool) -> None: + """Entry point for the `campus auth status` command. + + Exits 1 when not logged in (both modes) or when the token check + finds the stored token rejected; every other outcome — including + an unreachable server or an inconclusive check — is non-fatal. + """ + status = build_status(offline) + + if output_json: + # Plain stdout write: Rich's word wrap can split long values + # mid-string and corrupt machine-readable JSON. + typer.echo(json.dumps(status)) + else: + _print_human(status) + + if not status["authenticated"]: + raise typer.Exit(1) + if status["token_validity"] == VALIDITY_INVALID: + raise typer.Exit(1) diff --git a/campus_cli/credentials.py b/campus_cli/credentials.py index 905506c..7c06887 100644 --- a/campus_cli/credentials.py +++ b/campus_cli/credentials.py @@ -172,6 +172,59 @@ def delete_token(self) -> None: with contextlib.suppress(CredentialError): # May not exist (tokens stored before endpoint binding) self.delete_password("token_auth_url") + with contextlib.suppress(CredentialError): + # May not exist (tokens stored before scope recording, #45) + self.delete_password("token_scopes") + with contextlib.suppress(CredentialError): + # May not exist (tokens stored before principal recording, #45) + self.delete_password("token_user_id") + + def get_token_scopes(self) -> list[str] | None: + """ + Get the scopes the stored access token was minted with (#45). + + Tokens carry exactly the scopes requested at login (no + accumulation), so this is authoritative until the next login + replaces the token. + + Returns: + The scope list, or None for credentials stored before + scope recording was introduced (unknown, not empty). + """ + raw = self.get_password("token_scopes") + if raw is None: + return None + # Scopes are stored space-joined (RFC 6749 scope syntax: no + # spaces within a scope token), so split() round-trips. + return raw.split() + + def set_token_scopes(self, scopes: list[str]) -> None: + """ + Store the scopes the access token was minted with (#45). + + Args: + scopes: The granted scope list. + """ + self.set_password("token_scopes", " ".join(scopes)) + + def get_token_user_id(self) -> str | None: + """ + Get the principal (user id) the stored token was minted for (#45). + + Returns: + The user id, or None for credentials stored before + principal recording was introduced. + """ + return self.get_password("token_user_id") + + def set_token_user_id(self, user_id: str) -> None: + """ + Store the principal (user id) the token was minted for (#45). + + Args: + user_id: The authorizing user echoed by the device grant. + """ + self.set_password("token_user_id", user_id) def get_refresh_token(self) -> str | None: """ diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 70b012b..9d6c0f8 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -67,7 +67,7 @@ def test_auth_help(): def test_auth_status_not_authenticated(): - """Test auth status when not logged in.""" + """Test auth status when not logged in: friendly message, exit 1.""" # Mock credentials to ensure no token is stored (isolated test) with patch("campus_cli.auth.common.credentials") as mock_creds: mock_creds.get_token.return_value = None @@ -77,7 +77,7 @@ def test_auth_status_not_authenticated(): result = runner.invoke(app, ["auth", "status"]) - assert result.exit_code == 0 + assert result.exit_code == 1 assert "Not authenticated" in result.stdout @@ -235,8 +235,8 @@ def test_auth_status_json_format(): result = runner.invoke(app, ["auth", "status", "--json"]) - assert result.exit_code == 0 - assert '"authenticated":' in result.stdout + assert result.exit_code == 1 + assert '"authenticated": false' in result.stdout def test_client_help(): @@ -425,12 +425,20 @@ def test_vault_help(): def test_auth_status_authenticated(): """Test auth status when authenticated.""" # Mock credentials to simulate authenticated state - with patch("campus_cli.auth.common.credentials") as mock_creds: + with ( + patch("campus_cli.auth.common.credentials") as mock_creds, + patch( + "campus_cli.auth.status.probe_token_validity", + return_value=("valid", None), + ), + ): mock_creds.get_token.return_value = "test_access_token" mock_creds.get_refresh_token.return_value = "test_refresh_token" mock_creds.get_token_expires_at.return_value = "2024-12-31T23:59:59+00:00" mock_creds.is_token_expired.return_value = False mock_creds.get_token_auth_url.return_value = None + mock_creds.get_token_scopes.return_value = None + mock_creds.get_token_user_id.return_value = None result = runner.invoke(app, ["auth", "status"]) @@ -734,7 +742,13 @@ def test_auth_refresh_json_output_survives_long_tokens(): def test_auth_status_shows_endpoint_mismatch(): """A stored token from a different endpoint is flagged in status.""" - with patch("campus_cli.auth.common.credentials") as mock_creds: + with ( + patch("campus_cli.auth.common.credentials") as mock_creds, + patch( + "campus_cli.auth.status.probe_token_validity", + return_value=("valid", None), + ) as mock_probe, + ): mock_creds.get_token.return_value = "test_access_token" mock_creds.get_refresh_token.return_value = None mock_creds.get_token_expires_at.return_value = None @@ -742,17 +756,30 @@ def test_auth_status_shows_endpoint_mismatch(): mock_creds.get_token_auth_url.return_value = ( "https://auth-other.example.com/auth/v1" ) + mock_creds.get_token_scopes.return_value = None + mock_creds.get_token_user_id.return_value = None result = runner.invoke(app, ["auth", "status"]) assert result.exit_code == 0 assert "Endpoint mismatch" in result.stdout assert "https://auth-other.example.com/auth/v1" in result.stdout + # The validity probe follows the token: it asks the endpoint + # that minted it, not the CLI's current target. + assert mock_probe.call_args.args[0] == ( + "https://auth-other.example.com/auth/v1" + ) def test_auth_status_json_includes_endpoint_binding(): """auth status --json exposes both endpoints and the match verdict.""" - with patch("campus_cli.auth.common.credentials") as mock_creds: + with ( + patch("campus_cli.auth.common.credentials") as mock_creds, + patch( + "campus_cli.auth.status.probe_token_validity", + return_value=("valid", None), + ), + ): mock_creds.get_token.return_value = "test_access_token" mock_creds.get_refresh_token.return_value = None mock_creds.get_token_expires_at.return_value = None @@ -760,6 +787,8 @@ def test_auth_status_json_includes_endpoint_binding(): mock_creds.get_token_auth_url.return_value = ( "https://auth-other.example.com/auth/v1" ) + mock_creds.get_token_scopes.return_value = None + mock_creds.get_token_user_id.return_value = None result = runner.invoke(app, ["auth", "status", "--json"]) diff --git a/tests/unit/test_login.py b/tests/unit/test_login.py index 748154b..01d8634 100644 --- a/tests/unit/test_login.py +++ b/tests/unit/test_login.py @@ -269,3 +269,68 @@ def test_poll_for_token_clamps_zero_interval(): poll_for_token("device_code", interval=0, max_attempts=3) assert [c.args[0] for c in mock_sleep.call_args_list] == [1, 1, 1] + + +def _run_login_with_token_data(token_data): + """Drive login_cmd through a mocked device flow with token_data. + + Returns the mocked credential store so tests can assert what was + persisted. + """ + from typer.testing import CliRunner + + from campus_cli.cli import app + + device_data = { + "device_code": "dc", + "user_code": "ABCD-EFGH", + "verification_uri": "https://auth.example.com/activate", + "interval": 1, + "expires_in": 300, + } + creds = mock.Mock() + creds.get_token.return_value = None # no short-circuit + with ( + mock.patch("campus_cli.auth.login.credentials", creds), + mock.patch( + "campus_cli.auth.login.request_device_code", return_value=device_data + ), + mock.patch( + "campus_cli.auth.login.poll_for_token", return_value=token_data + ), + mock.patch("campus_cli.auth.login.webbrowser.open"), + mock.patch("campus_cli.auth.login.copy_to_clipboard"), + mock.patch( + "campus_cli.auth.login.create_login_session", return_value=None + ), + ): + result = CliRunner().invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + return creds + + +def test_login_records_scopes_and_principal(): + """The granted scopes and authorizing user are stored (#45).""" + creds = _run_login_with_token_data({ + "access_token": "tok-123", + "refresh_token": "refresh-123", + "expires_in": 3600, + "scope": "read clients:write", + "user_id": "user-alice@example.com", + }) + + creds.set_token_scopes.assert_called_once_with(["read", "clients:write"]) + creds.set_token_user_id.assert_called_once_with("user-alice@example.com") + creds.set_token_auth_url.assert_called_once() + + +def test_login_without_scope_response_records_neither(): + """Older servers that omit scope/user_id store neither (#45).""" + creds = _run_login_with_token_data({ + "access_token": "tok-123", + "refresh_token": "refresh-123", + "expires_in": 3600, + }) + + creds.set_token_scopes.assert_not_called() + creds.set_token_user_id.assert_not_called() diff --git a/tests/unit/test_status.py b/tests/unit/test_status.py new file mode 100644 index 0000000..306fe69 --- /dev/null +++ b/tests/unit/test_status.py @@ -0,0 +1,380 @@ +"""Unit tests for `campus auth status` (#45): assembly, formatting, probe. + +All tests run against a mocked credential store and a mocked probe, so +no test ever touches the network. +""" + +import json +from datetime import datetime, timedelta, timezone +from unittest import mock + +import requests +from typer.testing import CliRunner + +from campus_cli.auth.status import ( + VALIDITY_INVALID, + VALIDITY_NOT_CHECKED, + VALIDITY_UNKNOWN, + VALIDITY_UNREACHABLE, + VALIDITY_VALID, + build_status, + probe_token_validity, +) +from campus_cli.cli import app + +runner = CliRunner() + + +def _mock_credentials(**overrides): + """Build a credential-store mock describing an authenticated user.""" + creds = mock.Mock() + creds.get_token.return_value = "test_access_token" + creds.get_refresh_token.return_value = "test_refresh_token" + # Default expiry: one hour out, so is_token_expired-style parsing + # has a valid timestamp to work with. + creds.get_token_expires_at.return_value = ( + datetime.now(timezone.utc) + timedelta(hours=1) + ).isoformat() + creds.is_token_expired.return_value = False + creds.get_token_auth_url.return_value = ( + "https://auth-minted.example.com/auth/v1" + ) + creds.get_token_scopes.return_value = ["read", "write"] + creds.get_token_user_id.return_value = "user-alice@example.com" + for key, value in overrides.items(): + setattr(creds, key, mock.Mock(return_value=value)) + return creds + + +class TestProbeTokenValidity: + """The probe maps status codes to the three-state machine.""" + + def _get(self, status_code): + response = mock.Mock(spec=requests.Response) + response.status_code = status_code + with mock.patch( + "campus_cli.auth.status.requests.get", return_value=response + ) as mock_get: + result = probe_token_validity( + "https://auth.example.com/auth/v1", "tok-123" + ) + mock_get.assert_called_once_with( + "https://auth.example.com/auth/v1/users/", + headers={"Authorization": "Bearer tok-123"}, + timeout=mock.ANY, + ) + return result + + def test_forbidden_means_valid(self): + """403 FORBIDDEN: authenticated, just not allowed to list users.""" + assert self._get(403) == (VALIDITY_VALID, None) + + def test_ok_means_valid(self): + """200: a users:read token that listed users is valid too.""" + assert self._get(200) == (VALIDITY_VALID, None) + + def test_unauthorized_means_invalid(self): + """401 UNAUTHORIZED: the server rejected the token.""" + assert self._get(401) == (VALIDITY_INVALID, None) + + def test_other_status_is_unknown(self): + """Anything else (e.g. 5xx) is inconclusive, with a note.""" + validity, note = self._get(500) + assert validity == VALIDITY_UNKNOWN + assert "500" in note + + def test_network_error_is_unreachable(self): + """Connection failures are their own state, not a verdict.""" + with mock.patch( + "campus_cli.auth.status.requests.get", + side_effect=requests.ConnectionError("connection refused"), + ): + validity, note = probe_token_validity( + "https://auth.example.com/auth/v1", "tok-123" + ) + assert validity == VALIDITY_UNREACHABLE + assert "connection refused" in note + + +class TestBuildStatusOffline: + """--offline assembles everything the store holds, network-free.""" + + def test_offline_reports_stored_identity_without_probe(self): + """The full identity block comes from the store; no probe call.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity" + ) as mock_probe, + ): + status = build_status(offline=True) + + mock_probe.assert_not_called() + assert status["authenticated"] is True + assert status["client_id"] == "guest" + assert status["user_id"] == "user-alice@example.com" + assert status["scopes"] == ["read", "write"] + assert status["token_validity"] == VALIDITY_NOT_CHECKED + assert status["probe_url"] is None + assert status["expires_in_seconds"] is not None + assert 3500 < status["expires_in_seconds"] <= 3600 + + def test_offline_keeps_get_token_status_fields(self): + """The pre-#45 --json keys stay present and unchanged.""" + creds = _mock_credentials() + with mock.patch("campus_cli.auth.common.credentials", creds): + status = build_status(offline=True) + + assert status["auth_url"] + assert status["token_auth_url"] == ( + "https://auth-minted.example.com/auth/v1" + ) + # The mock's issuing endpoint is never the resolved target, + # so the mismatch verdict is deterministically False here. + assert status["endpoint_match"] is False + assert status["can_refresh"] is True + assert status["expires_at"] == creds.get_token_expires_at.return_value + + def test_unrecorded_scopes_and_principal_are_none(self): + """Credentials stored before #45 read as unknown, not empty.""" + creds = _mock_credentials( + get_token_scopes=None, get_token_user_id=None + ) + with mock.patch("campus_cli.auth.common.credentials", creds): + status = build_status(offline=True) + + assert status["scopes"] is None + assert status["user_id"] is None + + +class TestBuildStatusProbe: + """The default path probes the endpoint the token is bound to.""" + + def test_probe_targets_issuing_endpoint(self): + """A bound token is checked against the endpoint that minted it.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ) as mock_probe, + ): + status = build_status(offline=False) + + mock_probe.assert_called_once_with( + "https://auth-minted.example.com/auth/v1", "test_access_token" + ) + assert status["token_validity"] == VALIDITY_VALID + assert status["probe_url"] == ( + "https://auth-minted.example.com/auth/v1" + ) + + def test_unbound_token_probes_current_target(self): + """Pre-endpoint-binding credentials fall back to the target.""" + creds = _mock_credentials(get_token_auth_url=None) + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ) as mock_probe, + ): + status = build_status(offline=False) + + assert status["probe_url"] == status["auth_url"] + assert mock_probe.call_args.args[0] == status["auth_url"] + + def test_unreachable_probe_carries_note(self): + """The probe's note lands in the payload for both outputs.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_UNREACHABLE, "connection refused"), + ), + ): + status = build_status(offline=False) + + assert status["token_validity"] == VALIDITY_UNREACHABLE + assert "connection refused" in status["probe_note"] + + def test_not_authenticated_skips_probe_and_identity(self): + """No stored token: no probe, no identity, no validity verdict.""" + creds = _mock_credentials(get_token=None) + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity" + ) as mock_probe, + ): + status = build_status(offline=False) + + mock_probe.assert_not_called() + assert status["authenticated"] is False + assert status["user_id"] is None + assert status["scopes"] is None + assert status["token_validity"] is None + + +class TestStatusCliOutput: + """Human and JSON rendering of the assembled payload.""" + + def test_json_includes_all_fields(self): + """--json emits the same fields machine-readably, exit 0.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ), + ): + result = runner.invoke(app, ["auth", "status", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["authenticated"] is True + assert payload["client_id"] == "guest" + assert payload["user_id"] == "user-alice@example.com" + assert payload["scopes"] == ["read", "write"] + assert payload["token_validity"] == "valid" + + def test_human_output_shows_client_principal_and_scopes(self): + """The terminal report names client, principal and scopes.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ), + ): + result = runner.invoke(app, ["auth", "status"]) + + assert result.exit_code == 0 + assert "Authenticated" in result.stdout + assert "guest" in result.stdout + assert "user-alice@example.com" in result.stdout + assert "read" in result.stdout + assert "valid" in result.stdout + + def test_human_output_notes_unrecorded_scopes(self): + """Pre-#45 credentials say 'not recorded', never an empty list.""" + creds = _mock_credentials( + get_token_scopes=None, get_token_user_id=None + ) + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ), + ): + result = runner.invoke(app, ["auth", "status", "--offline"]) + + assert result.exit_code == 0 + assert "not recorded" in result.stdout + + def test_human_output_renders_empty_scope_list(self): + """A token recorded with zero scopes prints '(none)'.""" + creds = _mock_credentials(get_token_scopes=[]) + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_VALID, None), + ), + ): + result = runner.invoke(app, ["auth", "status", "--offline"]) + + assert result.exit_code == 0 + assert "(none)" in result.stdout + + def test_offline_flag_skips_the_probe(self): + """--offline says the check was skipped and never calls it.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity" + ) as mock_probe, + ): + result = runner.invoke(app, ["auth", "status", "--offline"]) + + mock_probe.assert_not_called() + assert result.exit_code == 0 + assert "skipped" in result.stdout + + def test_invalid_token_exits_nonzero_with_remedy(self): + """A 401 verdict is actionable: exit 1 and name re-login.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_INVALID, None), + ), + ): + result = runner.invoke(app, ["auth", "status"]) + + assert result.exit_code == 1 + assert "invalid" in result.stdout + assert "campus auth login" in result.stdout + + def test_invalid_token_nonzero_in_json_mode_too(self): + """--json keeps the same exit contract for scripts.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_INVALID, None), + ), + ): + result = runner.invoke(app, ["auth", "status", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["token_validity"] == "invalid" + + def test_unreachable_server_is_nonfatal(self): + """Network failure warns but exits 0: identity still printed.""" + creds = _mock_credentials() + with ( + mock.patch("campus_cli.auth.common.credentials", creds), + mock.patch( + "campus_cli.auth.status.probe_token_validity", + return_value=(VALIDITY_UNREACHABLE, "connection refused"), + ), + ): + result = runner.invoke(app, ["auth", "status"]) + + assert result.exit_code == 0 + # Rich wraps long lines; compare on normalized whitespace. + flattened = " ".join(result.stdout.split()) + assert "could not reach server" in flattened + assert "Authenticated" in result.stdout + + def test_not_logged_in_human_mode(self): + """No stored credential: friendly message and exit 1.""" + creds = _mock_credentials(get_token=None) + with mock.patch("campus_cli.auth.common.credentials", creds): + result = runner.invoke(app, ["auth", "status"]) + + assert result.exit_code == 1 + assert "Not authenticated" in result.stdout + assert "campus auth login" in result.stdout + + def test_not_logged_in_json_mode(self): + """No stored credential with --json: structured, still exit 1.""" + creds = _mock_credentials(get_token=None) + with mock.patch("campus_cli.auth.common.credentials", creds): + result = runner.invoke(app, ["auth", "status", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["authenticated"] is False + assert payload["token_validity"] is None