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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/basic_memory/cli/commands/cloud/core_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,22 @@ async def _login():

@cloud_app.command()
def logout():
"""Remove stored OAuth tokens."""
config = ConfigManager().config
"""Remove stored OAuth tokens and clear cached workspace selection."""
config_manager = ConfigManager()
config = config_manager.config
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
auth.logout()

# Trigger: ending a session must invalidate the cached workspace.
# Why: a follow-up `bm cloud login` (often as a different user, or returning
# from an org workspace to personal) inherits the previous selection
# and silently routes everything through the wrong tenant. See #755.
# Outcome: re-login starts from a clean slate; the user picks again via
# `bm cloud workspace set-default` or per-project --workspace.
if config.default_workspace is not None:
config.default_workspace = None
config_manager.save_config(config)

console.print("[dim]API key (if configured) remains available for cloud project routing.[/dim]")


Expand Down
65 changes: 65 additions & 0 deletions tests/cli/test_cloud_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,68 @@ def test_login_authentication_failure(self, monkeypatch):
result = runner.invoke(app, ["cloud", "login"])
assert result.exit_code == 1
assert "Login failed" in result.stdout


class TestLogoutCommand:
"""Tests for `bm cloud logout`."""

@staticmethod
def _patch(monkeypatch, default_workspace):
class FakeConfig:
cloud_client_id = "cid"
cloud_domain = "https://auth.example.com"

def __init__(self):
self.default_workspace = default_workspace

saved: list[FakeConfig] = []
config_instance = FakeConfig()
logout_called = {"value": False}

class FakeConfigManager:
config = config_instance

def save_config(self, cfg):
saved.append(cfg)

class FakeAuth:
def __init__(self, **_kwargs):
pass

def logout(self):
logout_called["value"] = True

monkeypatch.setattr(
"basic_memory.cli.commands.cloud.core_commands.ConfigManager", FakeConfigManager
)
monkeypatch.setattr("basic_memory.cli.commands.cloud.core_commands.CLIAuth", FakeAuth)
return config_instance, saved, logout_called

def test_logout_clears_default_workspace(self, monkeypatch):
"""Regression for #755: logout must invalidate the cached workspace."""
config_instance, saved, logout_called = self._patch(
monkeypatch, default_workspace="tenant-org-123"
)
runner = CliRunner()

result = runner.invoke(app, ["cloud", "logout"])

assert result.exit_code == 0
assert logout_called["value"] is True
assert config_instance.default_workspace is None
# Save was called once because a non-None value needed clearing.
assert len(saved) == 1
assert saved[0].default_workspace is None

def test_logout_skips_save_when_no_default_workspace(self, monkeypatch):
"""If nothing was cached, logout shouldn't rewrite the config file."""
config_instance, saved, logout_called = self._patch(monkeypatch, default_workspace=None)
runner = CliRunner()

result = runner.invoke(app, ["cloud", "logout"])

assert result.exit_code == 0
assert logout_called["value"] is True
assert config_instance.default_workspace is None
# No save: avoid touching the file when there's nothing to clear.
assert saved == []
Loading