fix(cloud): reset default_workspace on logout; prompt workspace selection on login

- bm cloud logout now clears config.default_workspace so the next
  session is not silently routed to a stale tenant (e.g. an org
  workspace after migrating back to personal).
- bm cloud login now calls _select_default_workspace_on_login() after
  successful authentication: single workspace is auto-set as default,
  multiple workspaces present a numbered chooser, failures are non-fatal.
- Add TestLogoutCommand and TestLoginWorkspaceSelection test classes
  covering the new behaviours.

Fixes #755

Co-authored-by: Drew Cain <groksrc@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
claude[bot]
2026-04-18 01:47:36 +00:00
parent 1b39062ecd
commit ffdd9af359
2 changed files with 292 additions and 2 deletions
+210
View File
@@ -2,19 +2,25 @@
from __future__ import annotations
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, cast
import httpx
import pytest
from typer.testing import CliRunner
import basic_memory.config
import basic_memory.cli.commands.cloud.core_commands as core_cmd
from basic_memory.cli.app import app
from basic_memory.cli.commands.cloud.api_client import (
CloudAPIError,
SubscriptionRequiredError,
make_api_request,
)
from basic_memory.config import BasicMemoryConfig, ConfigManager
from basic_memory.schemas.cloud import WorkspaceInfo
class _StubAuth:
@@ -199,3 +205,207 @@ class TestLoginCommand:
result = runner.invoke(app, ["cloud", "login"])
assert result.exit_code == 1
assert "Login failed" in result.stdout
# ---------------------------------------------------------------------------
# Shared config fixture for tests that inspect persisted config state
# ---------------------------------------------------------------------------
class _ConfigFixtureMixin:
"""Sets up an isolated temp config directory for each test."""
@pytest.fixture(autouse=True)
def _setup_config(self, monkeypatch):
self.temp_dir = tempfile.mkdtemp()
temp_path = Path(self.temp_dir)
config_dir = temp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HOME", str(temp_path))
monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir))
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
self.config_manager = ConfigManager()
self.temp_path = temp_path
def _reset_cache(self):
basic_memory.config._CONFIG_CACHE = None
basic_memory.config._CONFIG_MTIME = None
basic_memory.config._CONFIG_SIZE = None
def _save_config(self, **kwargs):
cfg = BasicMemoryConfig(
projects={"main": {"path": str(self.temp_path / "main")}},
**kwargs,
)
self.config_manager.save_config(cfg)
self._reset_cache()
class _FakeAuthFactory:
"""Produces a CLIAuth-compatible stub that never touches the filesystem."""
def __init__(self, login_ok: bool = True):
self._login_ok = login_ok
def __call__(self, **_kwargs):
login_ok = self._login_ok
class _Auth:
async def login(self) -> bool:
return login_ok
def logout(self) -> None:
pass
return _Auth()
class TestLogoutCommand(_ConfigFixtureMixin):
"""Tests for 'bm cloud logout' — token clearing and workspace reset."""
def test_logout_clears_default_workspace(self, monkeypatch):
self._save_config(default_workspace="11111111-1111-1111-1111-111111111111")
monkeypatch.setattr(core_cmd, "CLIAuth", _FakeAuthFactory())
runner = CliRunner()
result = runner.invoke(app, ["cloud", "logout"])
assert result.exit_code == 0
self._reset_cache()
config = ConfigManager().config
assert config.default_workspace is None
def test_logout_when_no_default_workspace(self, monkeypatch):
self._save_config()
monkeypatch.setattr(core_cmd, "CLIAuth", _FakeAuthFactory())
runner = CliRunner()
result = runner.invoke(app, ["cloud", "logout"])
assert result.exit_code == 0
self._reset_cache()
config = ConfigManager().config
assert config.default_workspace is None
class TestLoginWorkspaceSelection(_ConfigFixtureMixin):
"""Tests for workspace selection step inside 'bm cloud login'."""
def _patch_login_deps(self, monkeypatch, workspaces, login_ok=True):
"""Patch all login dependencies for a successful login scenario."""
self._save_config()
monkeypatch.setattr(core_cmd, "CLIAuth", _FakeAuthFactory(login_ok=login_ok))
monkeypatch.setattr(
"basic_memory.cli.commands.cloud.core_commands.get_cloud_config",
lambda: ("client_id", "domain", "https://cloud.example.com"),
)
async def fake_make_api_request(*_args, **_kwargs):
return httpx.Response(200, json={"status": "healthy"})
monkeypatch.setattr(core_cmd, "make_api_request", fake_make_api_request)
async def fake_get_workspaces(context=None):
return workspaces
monkeypatch.setattr(core_cmd, "get_available_workspaces", fake_get_workspaces)
def test_login_single_workspace_auto_sets_default(self, monkeypatch):
ws = WorkspaceInfo(
tenant_id="aaaa-1111",
workspace_type="personal",
name="Personal",
role="owner",
)
self._patch_login_deps(monkeypatch, [ws])
runner = CliRunner()
result = runner.invoke(app, ["cloud", "login"])
assert result.exit_code == 0
assert "Personal" in result.stdout
self._reset_cache()
config = ConfigManager().config
assert config.default_workspace == "aaaa-1111"
def test_login_multiple_workspaces_user_selects(self, monkeypatch):
ws1 = WorkspaceInfo(
tenant_id="aaaa-1111",
workspace_type="personal",
name="Personal",
role="owner",
)
ws2 = WorkspaceInfo(
tenant_id="bbbb-2222",
workspace_type="organization",
name="Team",
role="editor",
)
self._patch_login_deps(monkeypatch, [ws1, ws2])
runner = CliRunner()
# User types "2" to select the second workspace
result = runner.invoke(app, ["cloud", "login"], input="2\n")
assert result.exit_code == 0
assert "Team" in result.stdout
self._reset_cache()
config = ConfigManager().config
assert config.default_workspace == "bbbb-2222"
def test_login_multiple_workspaces_user_skips(self, monkeypatch):
ws1 = WorkspaceInfo(
tenant_id="aaaa-1111",
workspace_type="personal",
name="Personal",
role="owner",
)
ws2 = WorkspaceInfo(
tenant_id="bbbb-2222",
workspace_type="organization",
name="Team",
role="editor",
)
self._patch_login_deps(monkeypatch, [ws1, ws2])
runner = CliRunner()
# User presses Enter to skip selection
result = runner.invoke(app, ["cloud", "login"], input="\n")
assert result.exit_code == 0
assert "bm cloud workspace set-default" in result.stdout
self._reset_cache()
config = ConfigManager().config
# No workspace should be auto-set
assert config.default_workspace is None
def test_login_workspace_discovery_failure_is_nonfatal(self, monkeypatch):
self._save_config()
monkeypatch.setattr(core_cmd, "CLIAuth", _FakeAuthFactory())
monkeypatch.setattr(
"basic_memory.cli.commands.cloud.core_commands.get_cloud_config",
lambda: ("client_id", "domain", "https://cloud.example.com"),
)
async def fake_make_api_request(*_args, **_kwargs):
return httpx.Response(200, json={"status": "healthy"})
monkeypatch.setattr(core_cmd, "make_api_request", fake_make_api_request)
async def fail_get_workspaces(context=None):
raise RuntimeError("no connection")
monkeypatch.setattr(core_cmd, "get_available_workspaces", fail_get_workspaces)
runner = CliRunner()
result = runner.invoke(app, ["cloud", "login"])
# Login should still succeed despite workspace discovery failure
assert result.exit_code == 0
assert "Cloud authentication successful" in result.stdout
assert "Workspace discovery unavailable" in result.stdout