diff --git a/novem/api_ref.py b/novem/api_ref.py index 4ba5742..17c1636 100644 --- a/novem/api_ref.py +++ b/novem/api_ref.py @@ -31,18 +31,30 @@ def get_ua(is_cli: bool) -> Dict[str, str]: class NovemException(Exception): - pass + @property + def cli_message(self) -> str: + """The message to show when raised under the CLI. + + Defaults to the full exception text. Subclasses may override to drop + hints that only make sense for library callers — the CLI validates the + token up front (the startup whoami check), so it doesn't need the + "are you authenticated?" nudge. + """ + return str(self) class Novem404(NovemException): def __init__(self, message: str): - # 404 errors can occur if users are not authenticated, let them know - # future improvement: consider requesting a fixed endpoint (like - # whoami) and notify if not authenticated - message = f"Resource not found: {message} (Are you authenticated?)" + # 404s can also be how the API answers an unauthenticated request, so + # library callers (which have no up-front auth check) get a nudge. The + # CLI validates auth at startup, so its cli_message omits it. + self._detail = f"Resource not found: {message}" + super().__init__(f"{self._detail} (Are you authenticated?)") - super().__init__(message) + @property + def cli_message(self) -> str: + return self._detail class Novem403(NovemException): diff --git a/novem/cli/__init__.py b/novem/cli/__init__.py index 999da8b..328b7ed 100644 --- a/novem/cli/__init__.py +++ b/novem/cli/__init__.py @@ -20,6 +20,7 @@ import readline # type: ignore from ..api_ref import NovemAPI +from ..config import config as _config_manager from ..utils import cl, colors, get_config_path, get_current_config from ..version import __version__ from .common import doc, grid, job, mail, plot, user @@ -34,8 +35,14 @@ def _cli_excepthook(exc_type: type, exc_value: BaseException, exc_traceback: Any) -> None: """Custom exception handler for CLI mode - suppresses tracebacks unless in debug mode.""" - # Just print the exception message without traceback - print(f"{exc_type.__name__}: {exc_value}", file=sys.stderr) + # Our own API exceptions already carry a user-facing message, so surface + # just that (via cli_message, which also drops library-only hints) — the + # internal class name ("Novem404: …") is noise to a CLI user. Unexpected + # errors keep their type to aid debugging. + if isinstance(exc_value, NovemException): + print(exc_value.cli_message, file=sys.stderr) + else: + print(f"{exc_type.__name__}: {exc_value}", file=sys.stderr) # Server enforces ^[a-z][a-z0-9\-\._]*$ and length <= 128 on token names @@ -399,6 +406,45 @@ def print_short(parser: Any) -> None: print(" novem -u list your connections") +def _exit_if_token_rejected(args: Mapping[str, Any]) -> None: + """CLI guard: stop early when a configured token is rejected by the server. + + Without this, a stale or expired token silently degrades to empty (or + another user's public) results instead of telling the user to re-auth. + Only a definitive 401/403 aborts; transient/network errors fall through so + the real command can surface its own error. + + The whoami response does double duty: a successful one seeds the shared + identity cache, so the command's own current-user lookups reuse it instead + of fetching ``/whoami`` again. + """ + _, cfg = get_current_config(**config_from_args(args)) + token = cfg.get("token") + if not token: + # Anonymous / unconfigured: not a dead token — let normal flow handle it. + return + + try: + novem = NovemAPI(**config_from_args(args), is_cli=True) + resp = novem._session.get(f"{novem._api_root}whoami") + except Exception: + # Network/transient (or unmocked in tests): don't block the command. + return + + if resp.status_code == 200: + _config_manager.cache_identity(token, resp.text.strip()) + return + + if resp.status_code in (401, 403): + print( + f"{cl.WARNING} ! {cl.ENDC}Your novem token is invalid or has expired.\n" + f" Re-authenticate with: {cl.OKCYAN}novem --init{cl.ENDC}", + file=sys.stderr, + ) + sys.exit(1) + # Any other status: let the command run and surface its own error. + + def run_cli_wrapped() -> None: colors() @@ -682,6 +728,12 @@ def run_cli_wrapped() -> None: qpr = f"{qpr}cols={sz.columns},rows={sz.lines - prompt_lines}" args["qpr"] = qpr + # CLI-only: a configured-but-rejected token aborts here with a clear + # message, rather than letting the data commands below silently return + # empty/public results. Also seeds the resolved-identity cache. + if args: + _exit_if_token_rejected(args) + # operate on org group vis listing (if -O -G -p/-m/-g/-j with no vis ID) if args and args.get("org") and args.get("group") and args.get("plot") is None and "plot" in args: list_org_group_vis(args, "Plot") diff --git a/novem/cli/common.py b/novem/cli/common.py index 7d26bbe..eebcba3 100644 --- a/novem/cli/common.py +++ b/novem/cli/common.py @@ -6,7 +6,7 @@ from novem.api_ref import Novem404, NovemAPI from novem.cli.config import config_from_args from novem.cli.editor import edit -from novem.cli.gql import NovemGQL, _build_var_lookup, fetch_vde_topics_gql, render_topics +from novem.cli.gql import NovemGQL, _build_var_lookup, _fetch_vde_topics_gql, render_topics from novem.cli.setup import Share, Tag from novem.cli.vis import ( list_job_shares, @@ -173,8 +173,9 @@ def __call__(self, args: CliArgs) -> None: # --comments: show topics and comment threads if args.get("comments"): gql = NovemGQL.from_args(args) - topics, vde_vars = fetch_vde_topics_gql(gql, self.fragment, name, author=usr) - me = gql._config.get("username", "") + # The topics query already resolves the current user (token-based), + # so reuse it for the "me" highlight instead of a second round-trip. + topics, vde_vars, me = _fetch_vde_topics_gql(gql, self.fragment, name, author=usr) var_lookup = _build_var_lookup(vde_vars, usr or "", self.fragment, name) if vde_vars else None print( render_topics( diff --git a/novem/cli/gql.py b/novem/cli/gql.py index b0a4f40..359718c 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -42,6 +42,8 @@ def __init__(self, *, debug: bool = False, gql: Any = False, **connection: Any) self._debug = debug # Support both old gql_debug and new gql parameter for debug mode self._gql_debug = gql is True # True when --gql with no argument + # Token-resolved current username, lazily fetched via `me` and cached. + self._current_username: Optional[str] = None token = config.get("token") if token: @@ -105,139 +107,45 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict return result.get("data", {}) + @property + def current_username(self) -> str: + """The authenticated user's name, resolved from the token via ``whoami``. + + This is authoritative. The config-file ``username`` is only a cached + label and goes stale when a user is renamed on the backend, so any + identity decision (``is_me`` highlighting, building ``@user~group`` + paths, …) should use this instead. + + Resolved through the lightweight REST ``/whoami`` endpoint rather than a + GraphQL ``me`` round-trip. Returns ``""`` when there is no usable + identity (anonymous or bad token); callers degrade gracefully rather + than leaking another view. The result is shared per-token across the + process (see :class:`novem.config.ConfigManager`), so the CLI's + startup token check and any later lookups make at most one request. + """ + if self._current_username is None: + from ..config import config as _config_manager -# GraphQL query for listing plots/grids/mails -LIST_VIS_QUERY = """ -query ListVis($author: String, $limit: Int, $offset: Int) { - plots(author: $author, limit: $limit, offset: $offset) { - id - name - type - summary - url - updated - public - shared { - id - name - type - } - tags { - id - name - type - } - social { - views - } - topics { - num_comments - num_likes - num_dislikes - } - } -} -""" - -LIST_GRIDS_QUERY = """ -query ListGrids($author: String, $limit: Int, $offset: Int) { - grids(author: $author, limit: $limit, offset: $offset) { - id - name - type - summary - url - updated - public - shared { - id - name - type - } - tags { - id - name - type - } - social { - views - } - topics { - num_comments - num_likes - num_dislikes - } - } -} -""" - -LIST_MAILS_QUERY = """ -query ListMails($author: String, $limit: Int, $offset: Int) { - mails(author: $author, limit: $limit, offset: $offset) { - id - name - type - summary - url - updated - public - shared { - id - name - type - } - tags { - id - name - type - } - social { - views - } - topics { - num_comments - num_likes - num_dislikes - } - } -} -""" - -LIST_DOCS_QUERY = """ -query ListDocs($author: String, $limit: Int, $offset: Int) { - docs(author: $author, limit: $limit, offset: $offset) { - id - name - type - summary - url - updated - public - shared { - id - name - type - } - tags { - id - name - type - } - social { - views - } - topics { - num_comments - num_likes - num_dislikes - } - } -} -""" - -LIST_JOBS_QUERY = """ -query ListJobs($author: String, $limit: Int, $offset: Int) { - jobs(author: $author, limit: $limit, offset: $offset) { + token = self._config.get("token") or "" + cached = _config_manager.cached_identity(token) if token else None + if cached is not None: + self._current_username = cached + else: + try: + api_root = self._config.get("api_root") or API_ROOT + resp = self._session.get(f"{api_root.rstrip('/')}/whoami") + resp.raise_for_status() + self._current_username = resp.text.strip() + except Exception: + self._current_username = "" + _config_manager.cache_identity(token, self._current_username) + return self._current_username + + +# Field selection shared by every VDE listing (plots/grids/mails/docs/jobs), +# kept in one place so the root `author`-filtered queries and the token-scoped +# `me { ... }` queries can never drift apart. +_VIS_FIELDS = """ id name type @@ -245,34 +153,44 @@ def _query(self, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict url updated public - shared { - id - name - type - } - tags { - id - name - type - } - social { - views - } - topics { - num_comments - num_likes - num_dislikes - } + shared { id name type } + tags { id name type } + social { views } + topics { num_comments num_likes num_dislikes }""" + +# Jobs carry the VDE core plus run/schedule metadata. +_JOB_FIELDS = ( + _VIS_FIELDS + + """ last_run_status last_run_time run_count job_steps current_step schedule - triggers - } -} -""" + triggers""" +) + + +def _root_list_query(field: str, fields: str) -> str: + """A root listing query that filters by ``author`` (another user's view).""" + return ( + f"query List($author: String, $limit: Int, $offset: Int) {{\n" + f" {field}(author: $author, limit: $limit, offset: $offset) {{{fields}\n }}\n}}" + ) + + +def _me_list_query(field: str, fields: str) -> str: + """A listing query scoped to the token-resolved current user via ``me``.""" + return f"query Me {{\n me {{\n username\n {field} {{{fields}\n }}\n }}\n}}" + + +# GraphQL queries for listing another user's visualizations (--for-user). +LIST_VIS_QUERY = _root_list_query("plots", _VIS_FIELDS) +LIST_GRIDS_QUERY = _root_list_query("grids", _VIS_FIELDS) +LIST_MAILS_QUERY = _root_list_query("mails", _VIS_FIELDS) +LIST_DOCS_QUERY = _root_list_query("docs", _VIS_FIELDS) +LIST_JOBS_QUERY = _root_list_query("jobs", _JOB_FIELDS) LIST_USERS_QUERY = """ @@ -489,6 +407,49 @@ def list_jobs_gql(gql: NovemGQL, author: Optional[str] = None, limit: Optional[i return _transform_jobs_response(jobs) +def _list_me_vis(gql: NovemGQL, field: str, fields: str) -> List[Dict[str, Any]]: + """Fetch the current user's own VDEs of ``field`` via ``me``. + + Resolves identity from the token rather than a (possibly stale) config + username, so it keeps working across backend renames and never falls back + to another user's public view. + + A missing ``me`` (anonymous / bad token) yields an empty list today. A + friendlier "you are not authenticated" error belongs here and is handled + in a follow-up change. + """ + me = gql._query(_me_list_query(field, fields)).get("me") + if not me: + # TODO(stacked PR): surface an explicit auth error instead of empty. + return [] + return me.get(field, []) or [] + + +def list_my_plots_gql(gql: NovemGQL) -> List[Dict[str, Any]]: + """List the current user's own plots via ``me`` (token-scoped).""" + return _transform_vis_response(_list_me_vis(gql, "plots", _VIS_FIELDS)) + + +def list_my_grids_gql(gql: NovemGQL) -> List[Dict[str, Any]]: + """List the current user's own grids via ``me`` (token-scoped).""" + return _transform_vis_response(_list_me_vis(gql, "grids", _VIS_FIELDS)) + + +def list_my_mails_gql(gql: NovemGQL) -> List[Dict[str, Any]]: + """List the current user's own mails via ``me`` (token-scoped).""" + return _transform_vis_response(_list_me_vis(gql, "mails", _VIS_FIELDS)) + + +def list_my_docs_gql(gql: NovemGQL) -> List[Dict[str, Any]]: + """List the current user's own docs via ``me`` (token-scoped).""" + return _transform_vis_response(_list_me_vis(gql, "docs", _VIS_FIELDS)) + + +def list_my_jobs_gql(gql: NovemGQL) -> List[Dict[str, Any]]: + """List the current user's own jobs via ``me`` (token-scoped).""" + return _transform_jobs_response(_list_me_vis(gql, "jobs", _JOB_FIELDS)) + + def _transform_users_response(users: List[Dict[str, Any]], me_type: str) -> List[Dict[str, Any]]: """ Transform GraphQL users response for user listing. diff --git a/novem/cli/group.py b/novem/cli/group.py index fc8f4f6..2beb3e4 100644 --- a/novem/cli/group.py +++ b/novem/cli/group.py @@ -333,7 +333,7 @@ def group(args: CliArgs) -> None: print("Error: --comments requires a group name", file=sys.stderr) return - from .gql import NovemGQL, fetch_group_topics_gql, render_topics + from .gql import NovemGQL, _fetch_group_topics_gql, render_topics gql = NovemGQL.from_args(args) @@ -342,10 +342,13 @@ def group(args: CliArgs) -> None: parent = org_name else: group_type = "user_group" - parent = gql._config.get("username", "") + # Owner of a user group is the current user — resolve from the + # token, not the cached config username (stale after a rename). + parent = gql.current_username - topics = fetch_group_topics_gql(gql, group_name, group_type, parent) - me = gql._config.get("username", "") + # The topics query resolves the current user too, so reuse it for the + # "me" highlight instead of a second identity round-trip. + topics, me = _fetch_group_topics_gql(gql, group_name, group_type, parent) api_root = gql._config.get("api_root") or "https://api.novem.io/v1/" print( render_topics( diff --git a/novem/cli/vis.py b/novem/cli/vis.py index 5c825ff..2f05237 100644 --- a/novem/cli/vis.py +++ b/novem/cli/vis.py @@ -17,6 +17,11 @@ list_grids_gql, list_jobs_gql, list_mails_gql, + list_my_docs_gql, + list_my_grids_gql, + list_my_jobs_gql, + list_my_mails_gql, + list_my_plots_gql, list_org_group_members_gql, list_org_group_vis_gql, list_org_groups_gql, @@ -100,9 +105,10 @@ def list_vis(args: CliArgs, type: str) -> None: plist: List[Dict[str, Any]] = [] - usr = config.get("username") - if "for_user" in args and args["for_user"]: - usr = args["for_user"] + # An explicit --for-user lists *that* user's (public) visualizations; an + # empty value means "my own", which we resolve from the token via `me` + # rather than trusting the cached config username (stale after a rename). + for_user = args["for_user"] if ("for_user" in args and args["for_user"]) else "" # Use GraphQL for listing gql = NovemGQL.from_args(args) @@ -112,14 +118,17 @@ def list_vis(args: CliArgs, type: str) -> None: novem = NovemAPI(**config_from_args(args), is_cli=True) group = args["group"] org = args.get("org", "") - fu = args.get("for_user", "") if group[0] in ["@", "+"]: query = group - elif fu and group: - query = f"@{usr}~{group}" + elif for_user and group: + query = f"@{for_user}~{group}" elif org and group: query = f"+{org}~{group}" + elif group: + # My own user group — resolve identity from the token. + owner = gql.current_username + query = f"@{owner}~{group}" if owner else "" else: query = "" @@ -129,16 +138,26 @@ def list_vis(args: CliArgs, type: str) -> None: plist = json.loads(novem.read(path)) except Novem404: plist = [] + elif for_user: + # Another user's visualizations, filtered by author. + if pfx == "p": + plist = list_plots_gql(gql, author=for_user) + elif pfx == "g": + plist = list_grids_gql(gql, author=for_user) + elif pfx == "m": + plist = list_mails_gql(gql, author=for_user) + elif pfx == "d": + plist = list_docs_gql(gql, author=for_user) else: - # Use GraphQL for user's own visualizations + # My own visualizations, scoped to the token via `me`. if pfx == "p": - plist = list_plots_gql(gql, author=usr) + plist = list_my_plots_gql(gql) elif pfx == "g": - plist = list_grids_gql(gql, author=usr) + plist = list_my_grids_gql(gql) elif pfx == "m": - plist = list_mails_gql(gql, author=usr) + plist = list_my_mails_gql(gql) elif pfx == "d": - plist = list_docs_gql(gql, author=usr) + plist = list_my_docs_gql(gql) # Apply filters (handles both legacy and new column-based filtering) plist = apply_filters(plist, args.get("filter")) @@ -569,8 +588,8 @@ def list_users(args: CliArgs) -> None: # Apply filters plist = apply_filters(plist, args.get("filter")) - # Get current user's username - current_user = config.get("username", "") + # Get current user's username (token-resolved, not the cached config label) + current_user = gql.current_username # Sort by relevance: me first > connected > following > follower > groups > orgs > username def user_sort_key(u: Dict[str, Any]) -> Tuple[bool, bool, bool, bool, int, int, str]: @@ -765,13 +784,12 @@ def list_jobs(args: CliArgs) -> None: config_status, config = get_current_config(**config_from_args(args)) - usr = config.get("username") - if "for_user" in args and args["for_user"]: - usr = args["for_user"] + for_user = args["for_user"] if ("for_user" in args and args["for_user"]) else "" # Use GraphQL for listing gql = NovemGQL.from_args(args) - plist = list_jobs_gql(gql, author=usr) + # --for-user lists that user's jobs; otherwise list my own via `me`. + plist = list_jobs_gql(gql, author=for_user) if for_user else list_my_jobs_gql(gql) # Apply filters plist = apply_filters(plist, args.get("filter")) @@ -1256,10 +1274,9 @@ def list_org_users(args: CliArgs) -> None: print("Error: No org specified") return - current_user = config.get("username", "") - # Use GraphQL for listing gql = NovemGQL.from_args(args) + current_user = gql.current_username plist = list_org_members_gql(gql, org_id, current_user) # Apply filters @@ -1435,10 +1452,9 @@ def list_org_groups(args: CliArgs) -> None: print("Error: No org specified") return - current_user = config.get("username", "") - # Use GraphQL for listing gql = NovemGQL.from_args(args) + current_user = gql.current_username plist = list_org_groups_gql(gql, org_id, current_user) # Apply filters @@ -1784,10 +1800,9 @@ def list_org_group_users(args: CliArgs) -> None: print("Error: No group specified") return - current_user = config.get("username", "") - # Use GraphQL for listing gql = NovemGQL.from_args(args) + current_user = gql.current_username plist = list_org_group_members_gql(gql, org_id, group_id, current_user) # Apply filters diff --git a/novem/comments.py b/novem/comments.py index 80075cb..7186ee1 100644 --- a/novem/comments.py +++ b/novem/comments.py @@ -343,7 +343,11 @@ def me(self) -> str: def _threads_base(self) -> str: """REST API base path for threads on this resource.""" p = self._parsed - me = self._config.username or "" + # Prefer the token-resolved identity if the tree is already loaded; + # otherwise fall back to the config username rather than forcing a + # network round-trip just to build a path. (_me is populated by the + # topics fetch and reflects the real, post-rename name.) + me = self._me or self._config.username or "" # Org group: orgs/{org}/groups/{group}/threads if p.group_type == "org_group": @@ -540,7 +544,7 @@ def txt(self) -> str: from .cli.gql import render_topics self._load() - username = self._config.username or "" + username = self.me api_root = self._config.api_root session = self._session if hasattr(self, "_session") else None return render_topics( @@ -576,7 +580,7 @@ async def atxt(self) -> str: from .cli.gql import render_topics await self.aload() - username = self._config.username or "" + username = self.me api_root = self._config.api_root session = self._session if hasattr(self, "_session") else None return render_topics( diff --git a/novem/config.py b/novem/config.py index dee4114..0fc14d1 100644 --- a/novem/config.py +++ b/novem/config.py @@ -68,6 +68,9 @@ class ConfigManager: def __init__(self) -> None: self._overrides: Dict[str, Any] = {} + # Cache of token -> server-resolved username (via /whoami), so the + # identity only has to be fetched once per process per token. + self._identity: Dict[str, str] = {} def _set(self, key: str, value: Any) -> None: if value is None: @@ -100,6 +103,17 @@ def set_ignore_ssl(self, ignore_ssl: bool) -> None: def reset(self) -> None: """Clear all programmatically set overrides.""" self._overrides.clear() + self._identity.clear() + + # -- resolved-identity cache ----------------------------------------- + def cache_identity(self, token: str, username: str) -> None: + """Remember the server-resolved username for ``token`` (no-op if empty).""" + if token and username: + self._identity[token] = username + + def cached_identity(self, token: str) -> Optional[str]: + """Return the cached username for ``token``, or ``None`` if unknown.""" + return self._identity.get(token) def session( self, diff --git a/tests/test_cli_dead_token.py b/tests/test_cli_dead_token.py new file mode 100644 index 0000000..e6e94fa --- /dev/null +++ b/tests/test_cli_dead_token.py @@ -0,0 +1,93 @@ +import pytest + +from novem.cli.gql import _get_gql_endpoint +from novem.utils import API_ROOT +from tests.conftest import CliExit + +from .utils import write_config + +gql_endpoint = _get_gql_endpoint(API_ROOT) +whoami_url = f"{API_ROOT.rstrip('/')}/whoami" + +auth_req = { + "username": "demouser", + "token_name": "demotoken", +} + + +def test_dead_token_aborts_with_message(cli, requests_mock, fs): + """A configured-but-rejected token exits with a clear re-auth message.""" + write_config(auth_req) + + requests_mock.register_uri( + "get", + whoami_url, + json={"message": "Resource not found"}, + status_code=401, + ) + + with pytest.raises(CliExit) as ei: + cli("-p", "-l") + + assert ei.value.code == 1 + _out, err = ei.value.args + assert "invalid or has expired" in err + assert "novem --init" in err + + +def test_dead_token_does_not_reach_listing(cli, requests_mock, fs): + """The guard aborts before the listing query runs (no GQL leak).""" + write_config(auth_req) + + requests_mock.register_uri("get", whoami_url, status_code=401, json={}) + gql = requests_mock.register_uri("post", gql_endpoint, json={"data": {"me": None}}) + + with pytest.raises(CliExit): + cli("-p", "-l") + + assert gql.call_count == 0 + + +def test_valid_token_whoami_does_double_duty(cli, requests_mock, fs): + """A single whoami at startup validates the token *and* seeds identity. + + Listing users needs the current user (for is_me highlighting); with the + shared cache the startup check is the only /whoami request made. + """ + write_config(auth_req) + + requests_mock.register_uri("get", whoami_url, text="demouser", status_code=200) + requests_mock.register_uri( + "post", + gql_endpoint, + json={"data": {"users": []}}, + status_code=200, + ) + + out, _ = cli("-u") + + whoami_calls = [r for r in requests_mock.request_history if r.path.rstrip("/").endswith("whoami")] + assert len(whoami_calls) == 1 + + +def test_no_token_is_not_treated_as_dead(cli, requests_mock, fs): + """With no token at all we don't abort; the command runs (anonymous).""" + write_config({"username": "", "token_name": ""}) + # Blank out the token so the guard's "no token" branch is taken. + import configparser + + from novem.utils import get_config_path + + _, cpath = get_config_path() + cfg = configparser.ConfigParser() + cfg.read(cpath) + cfg["profile:demouser"]["token"] = "" + with open(cpath, "w") as f: + cfg.write(f) + + whoami = requests_mock.register_uri("get", whoami_url, status_code=401) + requests_mock.register_uri("post", gql_endpoint, json={"data": {"me": {"username": "", "plots": []}}}) + + # Should not raise / abort on a dead-token message. + cli("-p", "-l") + assert whoami.call_count == 0 diff --git a/tests/test_cli_error_output.py b/tests/test_cli_error_output.py new file mode 100644 index 0000000..7b59a54 --- /dev/null +++ b/tests/test_cli_error_output.py @@ -0,0 +1,28 @@ +from novem.cli import _cli_excepthook +from novem.exceptions import Novem404 + + +def test_excepthook_hides_internal_type_and_auth_hint(capsys): + """CLI output shows the detail, but not the class name or the auth hint.""" + exc = Novem404("/v1/vis/plots/asf/asdf/fad") + _cli_excepthook(type(exc), exc, None) + + err = capsys.readouterr().err + assert "Resource not found: /v1/vis/plots/asf/asdf/fad" in err + assert "Novem404" not in err + assert "(Are you authenticated?)" not in err # hint is library-only + + +def test_library_message_keeps_auth_hint(): + """Library callers (no up-front auth check) still get the hint via str().""" + exc = Novem404("/x") + assert str(exc) == "Resource not found: /x (Are you authenticated?)" + assert exc.cli_message == "Resource not found: /x" + + +def test_excepthook_keeps_type_for_unexpected_errors(capsys): + """Unexpected (non-novem) errors keep their type to aid debugging.""" + _cli_excepthook(ValueError, ValueError("boom"), None) + + err = capsys.readouterr().err + assert "ValueError: boom" in err diff --git a/tests/test_cli_grids.py b/tests/test_cli_grids.py index 50bbd07..7e1780e 100644 --- a/tests/test_cli_grids.py +++ b/tests/test_cli_grids.py @@ -247,11 +247,12 @@ def test_grid_list(cli, requests_mock, fs): }, ] - # Mock GraphQL endpoint + # Mock GraphQL endpoint. Listing one's own grids is now scoped to the + # token via `me { grids }`, so the response is nested under `me`. requests_mock.register_uri( "post", gql_endpoint, - json={"data": {"grids": gql_grid_list}}, + json={"data": {"me": {"username": "demouser", "grids": gql_grid_list}}}, status_code=200, ) diff --git a/tests/test_cli_plots.py b/tests/test_cli_plots.py index 3d1a903..e273b78 100644 --- a/tests/test_cli_plots.py +++ b/tests/test_cli_plots.py @@ -249,11 +249,12 @@ def test_plot_list(cli, requests_mock, fs): }, ] - # Mock GraphQL endpoint + # Mock GraphQL endpoint. Listing one's own plots is now scoped to the + # token via `me { plots }`, so the response is nested under `me`. requests_mock.register_uri( "post", gql_endpoint, - json={"data": {"plots": gql_plot_list}}, + json={"data": {"me": {"username": "demouser", "plots": gql_plot_list}}}, status_code=200, )