diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 52047c7..3d4fb87 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -49,6 +49,7 @@ get_databricks_token, install_databricks_cli, is_model_provider_feature_unavailable, + is_workspace_admin, list_profile_entries, list_tool_provider_services, normalize_workspace_url, @@ -118,6 +119,87 @@ } +def _policy_summary_lines(managed: dict) -> list[str]: + """Rich-markup lines describing the admin's budget policy, or empty when it sets none.""" + policy = managed.get("budget_policy") + if not isinstance(policy, dict): + return [] + name = str(policy.get("display_name") or "coding-agents-default") + lines = [f"[bold]Policy:[/bold] [cyan]{name}[/cyan]"] + tiers = policy.get("tiers") + for tier in tiers if isinstance(tiers, list) else []: + if not isinstance(tier, dict): + continue + pct_raw = tier.get("spending_percentage") + pct = ( + f"{float(pct_raw) * 100:g}%" + if isinstance(pct_raw, int | float) and not isinstance(pct_raw, bool) + else "?" + ) + # A tier whose agent enum this build doesn't know is dropped during normalization, so it + # arrives unset rather than as a tool name TOOL_SPECS could resolve. + agent = tier.get("default_agent") + agent_display = TOOL_SPECS[agent]["display"] if agent in TOOL_SPECS else "?" + model = str(tier.get("default_model") or "?") + lines.append( + f" [dim]·[/dim] [bold]at {pct}[/bold] → {agent_display} · [magenta]{model}[/magenta]" + ) + return lines + + +def _print_managed_summary(managed: dict, state: dict, tool: str) -> None: + """Show the developer which of their admin's settings are in force for this launch.""" + lines = [f"[bold]Workspace:[/bold] [cyan]{state.get('workspace', '?')}[/cyan]"] + lines.append(f"[bold]Agent:[/bold] [green]{TOOL_SPECS[tool]['display']}[/green]") + enabled = [t for t in (managed.get("enabled_agents") or {}) if t in TOOL_SPECS] + if enabled: + lines.append( + f"[bold]Enabled agents:[/bold] {', '.join(TOOL_SPECS[t]['display'] for t in enabled)}" + ) + provider = managed_provider_service(managed, tool) + if provider: + lines.append(f"[bold]Provider:[/bold] [magenta]{provider}[/magenta]") + model = managed_default_model(managed, tool) + if model: + lines.append(f"[bold]Model:[/bold] [magenta]{model}[/magenta]") + # Always listed, including when empty: "none configured" tells a developer their admin set none, + # which a missing row leaves ambiguous. Shown as the admin configured them — registering them + # locally is a separate change, hence "pending". + mcp_names = [ + str(server.get("name")) + for server in (managed.get("mcp_servers") or []) + if isinstance(server, dict) and server.get("name") + ] + if mcp_names: + lines.append(f"[bold]MCPs:[/bold] {', '.join(mcp_names)} [dim](pending)[/dim]") + else: + lines.append("[bold]MCPs:[/bold] [dim]none configured[/dim]") + skill_names = [str(name) for name in ((managed.get("skills") or {}).get("names") or []) if name] + if skill_names: + lines.append(f"[bold]Skills:[/bold] {', '.join(skill_names)} [dim](pending)[/dim]") + else: + lines.append("[bold]Skills:[/bold] [dim]none configured[/dim]") + lines.extend(_policy_summary_lines(managed)) + console.print( + Panel("\n".join(lines), title="Workspace-managed config", style="green", expand=False) + ) + + +def _reject_configure_under_managed_config() -> None: + """Refuse ``ucode configure`` when the workspace publishes a managed config. + + Configuring locally would be overridden at launch anyway, so it is an error rather than a + silently-ignored run. Without a managed config the command still runs unchanged. + """ + if not managed_agent_config_enabled(): + return + if load_managed_state(load_state().get("workspace")): + raise RuntimeError( + "The ucode configure command is being deprecated. Please run `ucode` to launch " + "with your admin's managed config applied" + ) + + def _print_discovery_diagnostics(state: dict) -> None: """Surface per-source reasons after a failed discovery so the user knows which API call returned what — instead of the generic 'no agents' line.""" @@ -866,7 +948,7 @@ def revert() -> int: app = typer.Typer( add_completion=False, - no_args_is_help=True, + no_args_is_help=False, context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, ) configure_app = typer.Typer(add_completion=False, no_args_is_help=False) @@ -883,22 +965,6 @@ def _version_callback(value: bool) -> None: raise typer.Exit() -@app.callback() -def _main( - version: Annotated[ - bool, - typer.Option( - "--version", - "-V", - help="Show the ucode version and exit.", - callback=_version_callback, - is_eager=True, - ), - ] = False, -) -> None: - """Configure and launch coding agents through Databricks AI Gateway.""" - - @mcp_app.command("web-search") def mcp_web_search_cmd() -> None: """Run the web_search MCP server over stdio. Invoked as a subprocess by Claude Code.""" @@ -1217,6 +1283,7 @@ def _launch_tool( skip_preflight: bool = False, workspace: str | None = None, enable_smart_routing_flag: bool = False, + managed: dict | None = None, ) -> None: try: tool = normalize_tool(tool_name) @@ -1246,7 +1313,10 @@ def _launch_tool( routing_agent = _ROUTING_AGENTS.get(tool) # Fetched before `configure_shared_state` because it decides whether this agent may launch # at all and whether the model discovery below can be skipped. - managed = _fetch_managed_config(state, skip_preflight=skip_preflight) + # Bare `ucode` already fetched one to choose the agent; refetching would double the + # control-plane round trip and any fallback warning it printed. + if managed is None: + managed = _fetch_managed_config(state, skip_preflight=skip_preflight) # Checked before discovery, which can take tens of seconds, so a blocked launch fails fast. _reject_disabled_agent(managed, tool) # Discovery exists to find models and isn't needed for managed config that already names them. @@ -1417,7 +1487,8 @@ def _launch_tool( typer.Option( "--skip-preflight", help="Skip the per-launch Databricks auth + AI Gateway re-validation, trusting a " - "prior `ucode configure`.", + "prior `ucode configure`. Launches with your own local settings, ignoring any " + "workspace managed config.", ), ] @@ -1433,6 +1504,128 @@ def _launch_tool( ] +@app.callback(invoke_without_command=True) +def default( + ctx: typer.Context, + version: Annotated[ + bool, + typer.Option( + "--version", + "-V", + help="Show the ucode version and exit.", + callback=_version_callback, + is_eager=True, + ), + ] = False, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", + help="Print config files without writing them. Uses the last saved managed " + "config instead of fetching a fresh one.", + ), + ] = False, + skip_preflight: SkipPreflightOption = False, + workspace: WorkspaceOption = None, +) -> None: + """Configure and launch coding agents through Databricks AI Gateway. + + With no subcommand, launches the agent your workspace's managed config selects. + """ + if ctx.invoked_subcommand is not None: + return + set_dry_run(dry_run) + try: + _launch_managed_default( + ctx, dry_run=dry_run, skip_preflight=skip_preflight, workspace=workspace + ) + except typer.Exit: + # `typer.Exit` subclasses RuntimeError, so it has to be re-raised ahead of the handler + # below. Otherwise a launch that already reported its own error is followed by + # `print_err(str(exc))` printing the exit code — a bare, meaningless "ERROR 1". + raise + except RuntimeError as exc: + print_err(str(exc)) + raise typer.Exit(1) from None + + +def _launch_managed_default( + ctx: typer.Context, + *, + dry_run: bool, + skip_preflight: bool, + workspace: str | None, +) -> None: + """Route bare ``ucode`` by whether the workspace publishes a managed config.""" + if not managed_agent_config_enabled(): + console.print(ctx.get_help()) + return + if workspace: + set_current_workspace(normalize_workspace_url(workspace)) + install_databricks_cli() + state = load_state() + current = state.get("workspace") + if not current: + raise RuntimeError("No workspace configured. Run `ucode configure` first.") + apply_pat_environment(state) + if skip_preflight: + # Deliberately unmanaged, so no config is read at all — and there is none to name an agent. + raise RuntimeError( + "--skip-preflight launches with your own settings, so `ucode` has no managed config " + "to pick an agent from. Run `ucode --skip-preflight` instead." + ) + # --dry-run avoids the fetch but still applies the last saved config. + if dry_run: + managed = load_managed_state(current) + else: + with spinner("Checking for a managed coding agent config..."): + managed = refresh_managed_config(state) + if not managed: + # Only a read that actually reached the workspace can say it publishes no config. Under + # --dry-run nothing was fetched, so an empty cache means "not pulled yet" — reporting that + # as "no config" would tell an admin their own published config doesn't exist. + if dry_run: + print_warning( + "No managed coding agent config is saved locally yet, so there is nothing to " + "dry-run. Run `ucode` without --dry-run to pull your workspace's config first." + ) + return + _print_no_managed_config_guidance(current, state.get("profile")) + return + tool = managed.get("default_agent") or next(iter(managed.get("enabled_agents") or {}), None) + if not isinstance(tool, str) or not tool: + raise RuntimeError( + "Your workspace's managed config names no agent to launch. Ask an admin to set a " + "default agent, or run `ucode ` directly." + ) + _print_managed_summary(managed, state, tool) + _launch_tool( + tool, + ctx, + skip_preflight=skip_preflight, + workspace=workspace, + managed=managed, + ) + + +def _print_no_managed_config_guidance(workspace: str, profile: str | None) -> None: + """Tell an admin how to publish a config, and everyone else who to ask.""" + print_warning( + "No managed coding agent config was found for this workspace; using your local settings." + ) + try: + token = get_databricks_token(workspace, profile) + except RuntimeError: + return + with spinner("Checking your workspace permissions..."): + is_admin = is_workspace_admin(workspace, token) + if is_admin is False: + print_note("Ask a workspace admin to set one up with `ucode setup`.") + else: + # None means the admin check itself failed; point at setup rather than a dead end. + print_note("Run `ucode setup` to configure one for your workspace, then `ucode apply`.") + + @app.command("codex", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def codex_cmd( ctx: typer.Context, @@ -1710,6 +1903,7 @@ def configure( prompt_optional_updates = not skip_upgrade try: install_databricks_cli() + _reject_configure_under_managed_config() if agent is not None and agents is not None: raise RuntimeError("Use either --agent or --agents, not both.") if workspaces is not None and profiles is not None: diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index b9e48bc..c912e2c 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -331,14 +331,44 @@ def _http_get_bytes(url: str, token: str, *, timeout: int = 10) -> tuple[bytes | return None, f"network error: {exc.reason}" +WORKSPACE_ADMIN_GROUP = "admins" + + +def _scim_me(workspace: str, token: str) -> dict | None: + """Return the SCIM `Me` payload for the caller, or None on failure.""" + hostname = workspace_hostname(workspace) + payload, _ = _http_get_json(f"https://{hostname}/api/2.0/preview/scim/v2/Me", token) + return payload if isinstance(payload, dict) else None + + +def is_workspace_admin(workspace: str, token: str) -> bool | None: + """Whether the caller is a workspace admin, via their SCIM `Me` group membership. + + Returns True/False, or None when the check itself could not be made (SCIM unreachable or a + malformed response), so callers can say "unknown" rather than misreport an admin as a + non-admin. + """ + payload = _scim_me(workspace, token) + if payload is None: + return None + groups = payload.get("groups") + if not isinstance(groups, list): + # A well-formed `Me` for a user in no groups omits `groups`, so this is a definitive + # "not an admin" rather than a failed check. + return False + return any( + isinstance(group, dict) and group.get("display") == WORKSPACE_ADMIN_GROUP + for group in groups + ) + + def get_current_user_name(workspace: str, token: str) -> str | None: """Return the current user's login (email) via SCIM `Me`, or None on failure. Databricks puts the workspace login in `userName`; fall back to the first `emails` entry for workspaces that diverge.""" - hostname = workspace_hostname(workspace) - payload, _ = _http_get_json(f"https://{hostname}/api/2.0/preview/scim/v2/Me", token) - if not isinstance(payload, dict): + payload = _scim_me(workspace, token) + if payload is None: return None user_name = payload.get("userName") if isinstance(user_name, str) and user_name.strip(): diff --git a/src/ucode/managed_config.py b/src/ucode/managed_config.py index c128d3c..90d4d58 100644 --- a/src/ucode/managed_config.py +++ b/src/ucode/managed_config.py @@ -24,7 +24,7 @@ import ucode.config_io as config_io from ucode.databricks import fetch_managed_coding_agent_configs, get_databricks_token -from ucode.ui import print_warning +from ucode.ui import console, print_warning MANAGED_STATE_PATH = config_io.APP_DIR / "managed-state.json" @@ -299,9 +299,13 @@ def save_managed_state(workspace: str, config: dict) -> None: file doubles as the fallback when a later read fails: without it, removing a config server-side would leave the old one on disk to be reapplied after a transient outage. """ + payload = {"workspace": workspace, "config": config} if config_io.is_dry_run(): + # Print rather than write, matching how the agent config writers behave under --dry-run. + console.print( + f"\n[bold]\\[dry run] {MANAGED_STATE_PATH}[/bold]\n{json.dumps(payload, indent=2)}\n" + ) return - payload = {"workspace": workspace, "config": config} config_io.ensure_parent_dir(MANAGED_STATE_PATH) try: MANAGED_STATE_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d374f5..eaa7551 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2207,3 +2207,215 @@ def test_a_removed_model_list_no_longer_skips_discovery(self, monkeypatch): assert result.exit_code == 0, result.output assert mock_shared.call_args.kwargs["skip_model_discovery"] is False + + +class TestConfigureDeprecation: + """`ucode configure` is refused once a managed config exists, since the admin's wins anyway.""" + + @staticmethod + def _reject(): + import ucode.cli as cli_mod + + cli_mod._reject_configure_under_managed_config() + + def test_blocks_when_a_managed_config_exists(self, monkeypatch): + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: {"enabled_agents": {}}) + with pytest.raises(RuntimeError, match="being deprecated") as exc: + self._reject() + assert "Please run `ucode`" in str(exc.value) + + def test_silent_and_proceeds_without_a_managed_config(self, monkeypatch, capsys): + # Setting up a new workspace still goes through `ucode configure`, so say nothing. + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: None) + self._reject() + assert capsys.readouterr().out == "" + + @pytest.mark.parametrize("env_value", [None, "", "0"]) + def test_silent_when_the_env_var_is_off(self, monkeypatch, capsys, env_value): + if env_value is None: + monkeypatch.delenv("ENABLE_MANAGED_AGENT_CONFIG", raising=False) + else: + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", env_value) + monkeypatch.setattr( + "ucode.cli.load_managed_state", + lambda ws: pytest.fail("must not read the config when disabled"), + ) + self._reject() + assert capsys.readouterr().out == "" + + +class TestPolicySummary: + """The box shown to a developer when their admin's config is applied.""" + + MANAGED = { + "default_agent": "claude", + "enabled_agents": {"claude": {"model_config": {"default_model": "system.ai.opus"}}}, + "budget_policy": { + "display_name": "paved-path", + # A fraction of the budget, as the API validates it: 0.8 renders as "at 80%". + "tiers": [ + {"spending_percentage": 0.8, "default_agent": "opencode", "default_model": "haiku"} + ], + }, + } + + def test_lists_the_tiers_and_the_applied_model(self, capsys): + import ucode.cli as cli_mod + + cli_mod._print_managed_summary(self.MANAGED, {"workspace": "https://w"}, "claude") + out = capsys.readouterr().out + assert "paved-path" in out + assert "at 80%" in out and "OpenCode" in out and "haiku" in out + assert "system.ai.opus" in out + + def test_lists_managed_mcps_and_skills(self, capsys): + import ucode.cli as cli_mod + + managed = { + **self.MANAGED, + "mcp_servers": [{"name": "system.ai.slack", "type": "mcp-service"}], + "skills": {"names": ["main.default.my_skill"]}, + } + cli_mod._print_managed_summary(managed, {"workspace": "https://w"}, "claude") + out = capsys.readouterr().out + assert "system.ai.slack" in out + assert "main.default.my_skill" in out + # Marked pending until ucode registers them locally. + assert "pending" in out + + def test_mcp_and_skill_rows_say_none_when_the_config_names_none(self, capsys): + import ucode.cli as cli_mod + + # Shown rather than omitted: a missing row leaves "my admin set none" ambiguous. + cli_mod._print_managed_summary(self.MANAGED, {"workspace": "https://w"}, "claude") + out = capsys.readouterr().out + assert "MCPs:" in out and "Skills:" in out + assert out.count("none configured") == 2 + assert "pending" not in out + + def test_no_policy_rows_without_a_budget_policy(self, capsys): + import ucode.cli as cli_mod + + cli_mod._print_managed_summary( + {"enabled_agents": {"claude": {}}}, {"workspace": "w"}, "claude" + ) + out = capsys.readouterr().out + assert "Policy:" not in out + assert "Claude Code" in out + + +class TestBareUcode: + """Bare `ucode` launches the managed default agent, or explains why it can't.""" + + MANAGED = {"default_agent": "claude", "enabled_agents": {"claude": {}, "opencode": {}}} + + @staticmethod + def _run(monkeypatch, *, managed, is_admin=False, args=None, cached=None): + launched: list[tuple] = [] + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") + monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.apply_pat_environment", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + monkeypatch.setattr("ucode.cli.refresh_managed_config", lambda state: managed) + monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: cached) + monkeypatch.setattr("ucode.cli.get_databricks_token", lambda *a, **k: "tok") + monkeypatch.setattr("ucode.cli.is_workspace_admin", lambda *a, **k: is_admin) + monkeypatch.setattr( + "ucode.cli._launch_tool", + lambda tool, ctx, **kw: launched.append((tool, kw)), + ) + result = runner.invoke(app, args or []) + return result, launched + + def test_launches_the_managed_default_agent(self, monkeypatch): + result, launched = self._run(monkeypatch, managed=self.MANAGED) + assert result.exit_code == 0, result.output + assert launched and launched[0][0] == "claude" + assert "paved" not in result.output # no policy set in this config + assert "Claude Code" in result.output + + def test_falls_back_to_the_first_enabled_agent(self, monkeypatch): + managed = {"enabled_agents": {"opencode": {}}} + result, launched = self._run(monkeypatch, managed=managed) + assert result.exit_code == 0, result.output + assert launched[0][0] == "opencode" + + def test_admin_without_a_config_is_pointed_at_setup(self, monkeypatch): + result, launched = self._run(monkeypatch, managed=None, is_admin=True) + assert result.exit_code == 0, result.output + assert launched == [] + assert "ucode setup" in result.output + + def test_non_admin_without_a_config_is_told_to_ask(self, monkeypatch): + result, launched = self._run(monkeypatch, managed=None, is_admin=False) + assert result.exit_code == 0, result.output + assert launched == [] + assert "Ask a workspace admin" in result.output + + def test_dry_run_uses_the_cache_and_does_not_fetch(self, monkeypatch): + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") + monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.apply_pat_environment", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + monkeypatch.setattr( + "ucode.cli.refresh_managed_config", + lambda state: pytest.fail("--dry-run must not fetch"), + ) + monkeypatch.setattr("ucode.cli.load_managed_state", lambda ws: self.MANAGED) + launched: list[tuple] = [] + monkeypatch.setattr( + "ucode.cli._launch_tool", lambda tool, ctx, **kw: launched.append((tool, kw)) + ) + result = runner.invoke(app, ["--dry-run"]) + assert result.exit_code == 0, result.output + # The config bare `ucode` already read is handed down, so the launch path does not refetch. + assert launched[0][1]["managed"] == self.MANAGED + + def test_skip_preflight_has_no_config_to_pick_an_agent_from(self, monkeypatch): + # --skip-preflight is deliberately unmanaged, so bare `ucode` cannot resolve an agent. It + # must say that rather than report "no config found", which would be wrong. + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") + monkeypatch.setattr("ucode.cli.install_databricks_cli", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.apply_pat_environment", lambda *a, **k: None) + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + monkeypatch.setattr( + "ucode.cli.refresh_managed_config", + lambda state: pytest.fail("--skip-preflight must not fetch"), + ) + monkeypatch.setattr( + "ucode.cli.load_managed_state", + lambda ws: pytest.fail("--skip-preflight must not read the cache"), + ) + result = runner.invoke(app, ["--skip-preflight"]) + assert result.exit_code == 1 + assert "ucode --skip-preflight" in result.output + assert "No managed coding agent config was found" not in result.output + + @pytest.mark.parametrize("env_value", [None, "", "0"]) + def test_prints_help_when_the_env_var_is_off(self, monkeypatch, env_value): + if env_value is None: + monkeypatch.delenv("ENABLE_MANAGED_AGENT_CONFIG", raising=False) + else: + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", env_value) + monkeypatch.setattr( + "ucode.cli.refresh_managed_config", + lambda state: pytest.fail("must not fetch when disabled"), + ) + result = runner.invoke(app, []) + assert result.exit_code == 0, result.output + assert "Usage:" in result.output + + def test_subcommands_still_work(self, monkeypatch): + # The callback runs for every invocation, so it must not intercept `ucode status`. + monkeypatch.setenv("ENABLE_MANAGED_AGENT_CONFIG", "1") + monkeypatch.setattr( + "ucode.cli.refresh_managed_config", + lambda state: pytest.fail("the callback must not run for a subcommand"), + ) + monkeypatch.setattr("ucode.cli.load_state", lambda: {"workspace": "https://w"}) + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0, result.output diff --git a/tests/test_databricks.py b/tests/test_databricks.py index cc7b027..a7afd47 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -2093,3 +2093,31 @@ class TestClassifyModelFamily: ) def test_buckets_by_family(self, model_id, expected): assert classify_model_family(model_id) == expected + + +class TestIsWorkspaceAdmin: + """Admin detection reuses the SCIM `Me` payload, which carries group membership.""" + + @staticmethod + def _stub(monkeypatch, payload): + monkeypatch.setattr(db_mod, "_scim_me", lambda ws, tok: payload) + + def test_true_when_in_the_admins_group(self, monkeypatch): + self._stub(monkeypatch, {"groups": [{"display": "users"}, {"display": "admins"}]}) + assert db_mod.is_workspace_admin("https://w", "tok") is True + + def test_false_without_the_admins_group(self, monkeypatch): + self._stub(monkeypatch, {"groups": [{"display": "users"}]}) + assert db_mod.is_workspace_admin("https://w", "tok") is False + + def test_none_when_the_check_could_not_be_made(self, monkeypatch): + # An unreachable SCIM is "unknown", not "not an admin" — the caller must not send a real + # admin down the non-admin dead end. + self._stub(monkeypatch, None) + assert db_mod.is_workspace_admin("https://w", "tok") is None + + @pytest.mark.parametrize("payload", [{}, {"groups": "not-a-list"}]) + def test_false_when_the_payload_names_no_groups(self, monkeypatch, payload): + # A well-formed `Me` for a user in no groups omits `groups` entirely. + self._stub(monkeypatch, payload) + assert db_mod.is_workspace_admin("https://w", "tok") is False