Files
hermes-webui/tests/test_issue1612_renamed_root_profile.py
T
nesquena-hermes 6bc0f9c4d5 Apply Opus pre-release SHOULD-FIX + NITs (in-PR per release policy)
SHOULD-FIX #1 (renamed-root client cross-alias): drop strict-equality client
filter at static/sessions.js:1853. Server-side _profiles_match cross-aliases
'default'-tagged rows to a renamed root 'kinni'; the strict-equality client
would reject them, dropping every legacy session for renamed-root users. The
server is now solely authoritative for profile scoping.

SHOULD-FIX #2 (messaging-source dedupe ordering): _keep_latest_messaging_session_per_source
now runs AFTER the profile filter at api/routes.py:2078. Before, it ran on
the merged-cross-profile list with profile-blind keys, discarding the older
profile's row across profiles before the scope filter — leaving zero rows for
any messaging identity the active profile shared with another profile.

NIT #3: _projects_migrated flag now set only AFTER successful save_projects.
NIT #4: cleaned dead test code in test_is_root_profile_invalidation_drops_stale.
NIT #5: _create_profile_fallback's clone_from=='default' literal now routes
through _is_root_profile() for parity with the 5 other callsites.

+2 regression tests pin the SHOULD-FIX shapes:
- test_keep_latest_messaging_runs_after_profile_filter (source-string ordering)
- test_static_sessions_js_trusts_server_profile_scoping (no client re-filter)

4173 -> 4175 tests pass. 0 regressions.
2026-05-04 16:17:26 +00:00

228 lines
8.6 KiB
Python

"""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
seq = [
[{'name': 'kinni', 'is_default': True, 'path': '/tmp/.hermes'}],
[{'name': 'noblepro', 'is_default': True, 'path': '/tmp/.hermes'}],
]
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()