HERMES-GIELIE/tests/test_issue1612_renamed_root_profile.py

231 lines
8.8 KiB
Python
Raw Normal View History

fix(profiles): scope sessions, projects, and root-profile resolution to active profile (#1611, #1612, #1614) Closes #1611 — /api/sessions filters by active profile by default; ?all_profiles=1 opt-in for aggregate views; new _profiles_match() helper honours renamed-root cross-aliasing; static/sessions.js drops the s.is_cli_session bypass; toggle-on re-fetches with all_profiles=1 instead of slicing client-cached rows. Closes #1612 — new _is_root_profile() central helper consults list_profiles_api() for is_default=True matches alongside the legacy 'default' alias. Replaces five literal-default callsites in api/profiles.py. Memoized with explicit invalidation hooks at create + delete. Sticky active_profile file write now stores '' for renamed root, consistent with the legacy empty==root contract. Closes #1614 — projects carry a profile field stamped at create-time; /api/projects filters by active profile; /api/projects/{create,rename,delete} and /api/session/move reject ops on cross-profile projects with 404; new _PROJECTS_MIGRATION migration in load_projects() back-tags untagged projects from any session that uses them, fall back to 'default'; ensure_cron_project keys lookup by (name, profile) so each profile gets its own Cron Jobs project. 31 regression tests (9+11+11) pin the renamed-root resolution, server-side profile scoping shape, helper invariants, cross-alias matching, migration behavior, and active-profile guards on every project mutation endpoint. 4148 tests pass. Reporter: @stefanpieter Co-authored-by: stefanpieter <noreply@github.com>
2026-05-04 15:59:52 +00:00
"""Tests for issue #1612: renamed root profile must resolve to ~/.hermes,
not ~/.hermes/profiles/<name>.
A renamed root/default Hermes profile (`is_default=True` on the agent side
but with a display name like `kinni`) was being treated as a named profile
directory under `~/.hermes/profiles/kinni`, which doesn't exist. Every
`if name == 'default':` site in api/profiles.py fell through to the wrong
filesystem path with `Profile 'kinni' does not exist.`
Fix: centralize the "is this the root?" check in `_is_root_profile(name)`
and replace each scattered `if name == 'default':` with it.
"""
import os
from pathlib import Path
from unittest.mock import patch
import pytest
# ── _is_root_profile core ───────────────────────────────────────────────────
def test_is_root_profile_default_alias():
"""Legacy 'default' literal always resolves as root, regardless of cache state."""
import api.profiles as p
p._invalidate_root_profile_cache()
assert p._is_root_profile('default') is True
def test_is_root_profile_empty_or_none_is_false():
"""Empty/None name is NOT root — caller code decides what to do."""
import api.profiles as p
assert p._is_root_profile('') is False
assert p._is_root_profile(None) is False
def test_is_root_profile_renamed_root_via_list_profiles_api(monkeypatch):
"""A profile name reported by list_profiles_api with is_default=True is treated as root."""
import api.profiles as p
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
{'name': 'kinni', 'is_default': True, 'path': str(p._DEFAULT_HERMES_HOME)},
{'name': 'haku', 'is_default': False, 'path': '/tmp/profiles/haku'},
])
p._invalidate_root_profile_cache()
assert p._is_root_profile('kinni') is True
assert p._is_root_profile('haku') is False
assert p._is_root_profile('default') is True
def test_is_root_profile_caches_results(monkeypatch):
"""Repeated calls don't re-invoke list_profiles_api — once-per-mutation memoization."""
import api.profiles as p
calls = {'n': 0}
def fake_list():
calls['n'] += 1
return [{'name': 'kinni', 'is_default': True, 'path': '/tmp/.hermes'}]
monkeypatch.setattr(p, 'list_profiles_api', fake_list)
p._invalidate_root_profile_cache()
p._is_root_profile('kinni')
p._is_root_profile('kinni')
p._is_root_profile('haku')
assert calls['n'] == 1, "Cache should be hit after first lookup"
def test_is_root_profile_invalidation_drops_stale(monkeypatch):
"""Explicit invalidation forces re-query on next call."""
import api.profiles as p
states = [
[{'name': 'kinni', 'is_default': True, 'path': '/tmp/.hermes'}],
[{'name': 'noblepro', 'is_default': True, 'path': '/tmp/.hermes'}],
]
monkeypatch.setattr(p, 'list_profiles_api', lambda: states[len(states) - len([s for s in states])])
# Simpler: pop pattern
seq = list(states)
monkeypatch.setattr(p, 'list_profiles_api', lambda: seq[0] if seq else [])
p._invalidate_root_profile_cache()
assert p._is_root_profile('kinni') is True
assert p._is_root_profile('noblepro') is False
# Simulate rename — drop first state, second is now the truth
seq.pop(0)
p._invalidate_root_profile_cache()
assert p._is_root_profile('kinni') is False
assert p._is_root_profile('noblepro') is True
def test_is_root_profile_handles_list_profiles_failure(monkeypatch):
"""If list_profiles_api raises, fall back to literal-default-only — never raise."""
import api.profiles as p
def boom():
raise RuntimeError("hermes_cli explosion")
monkeypatch.setattr(p, 'list_profiles_api', boom)
p._invalidate_root_profile_cache()
# 'default' still works (handled before list_profiles_api call).
assert p._is_root_profile('default') is True
# Other names return False on failure.
assert p._is_root_profile('kinni') is False
# ── get_active_hermes_home: returns _DEFAULT_HERMES_HOME for renamed root ──
def test_get_active_hermes_home_returns_default_for_renamed_root(tmp_path, monkeypatch):
"""The core bug: a renamed root profile must resolve to _DEFAULT_HERMES_HOME,
not _DEFAULT_HERMES_HOME / 'profiles' / <name>."""
import api.profiles as p
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
])
p._invalidate_root_profile_cache()
monkeypatch.setattr(p, '_active_profile', 'kinni')
result = p.get_active_hermes_home()
assert result == tmp_path, f"Expected {tmp_path}, got {result}"
def test_get_active_hermes_home_returns_named_for_real_named_profile(tmp_path, monkeypatch):
"""Backward compat: a real named (non-default) profile still resolves to profiles/<name>."""
import api.profiles as p
profile_dir = tmp_path / 'profiles' / 'haku'
profile_dir.mkdir(parents=True)
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
{'name': 'haku', 'is_default': False, 'path': str(profile_dir)},
])
p._invalidate_root_profile_cache()
monkeypatch.setattr(p, '_active_profile', 'haku')
result = p.get_active_hermes_home()
assert result == profile_dir
# ── switch_profile: accepts renamed root display name ─────────────────────
def test_switch_profile_resolution_renamed_root_picks_default_home(tmp_path, monkeypatch):
"""switch_profile()'s resolution branch: a renamed root must select
_DEFAULT_HERMES_HOME, not raise 'Profile <name> does not exist.'
We don't drive switch_profile() end-to-end (it touches reload_config,
workspace resolution, env mutation, etc.); instead we exercise the
same resolve-or-raise structure that lives at the head of switch_profile.
"""
import api.profiles as p
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
])
p._invalidate_root_profile_cache()
# Mirror switch_profile's resolution logic
name = 'kinni'
if p._is_root_profile(name):
home = p._DEFAULT_HERMES_HOME
else:
home = p._resolve_named_profile_home(name)
if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.")
assert home == tmp_path
# Sanity: a TRULY missing profile still raises (backward compat)
with pytest.raises(ValueError, match="does not exist"):
name = 'phantom'
if p._is_root_profile(name):
home = p._DEFAULT_HERMES_HOME
else:
home = p._resolve_named_profile_home(name)
if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.")
def test_switch_profile_sticky_marker_renamed_root(tmp_path, monkeypatch):
"""switch_profile writes '' (empty marker) to active_profile file when
switching to the root profile, regardless of its display name. This
means a subsequent boot reads '' → falls through to 'default' alias →
_is_root_profile('default') → resolves to _DEFAULT_HERMES_HOME, which
is the only correct location for the renamed-root case."""
import api.profiles as p
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
])
p._invalidate_root_profile_cache()
# Mirror the sticky-write line directly — guards that the new ternary
# uses _is_root_profile, not the literal-'default' compare.
written = '' if p._is_root_profile('kinni') else 'kinni'
assert written == ''
written2 = '' if p._is_root_profile('haku') else 'haku'
assert written2 == 'haku'
def test_delete_profile_blocks_renamed_root(tmp_path, monkeypatch):
"""delete_profile_api on a renamed root must refuse, same as 'default'."""
import api.profiles as p
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
])
p._invalidate_root_profile_cache()
with pytest.raises(ValueError, match="Cannot delete the default profile"):
p.delete_profile_api('kinni')
# ── Cleanup: invalidate cache between tests so they don't leak ─────────────
@pytest.fixture(autouse=True)
def _invalidate_cache_around_test():
import api.profiles as p
p._invalidate_root_profile_cache()
yield
p._invalidate_root_profile_cache()