test: pin REST profile scoping with real multi-profile activation
The destructive-route file covered 3 of the 12 gated routes and faked the gate itself (`monkeypatch` on `is_multiplex_active`), which left the boot wiring the whole 400 branch rests on untested. * All 12 destructive/privileged routes are parametrized through the refusal case, and the refusal now asserts the ABSENCE of the effect (no spawn, no session store opened, no file touched) instead of the detail wording. * Multi-profile hosting is armed for real: `activate_multi_profile_hosting_eagerly()` (the boot probe) for the refusal cases, plus a test that a cross-profile request is itself the lazy activation. An autouse fixture enters each test single-profile and restores the process-global flag and frozen launch-env snapshot, so the one-way flip cannot leak into the single-profile-host test. * New coverage for the scoping that had none: PUT /api/dashboard/plugin-providers, POST /api/local-models/quickstart, memory-provider readiness judged inside the write scope, and one invariant each for status/actions/config_env/memory_providers. * web: ?profile= on /api/credentials, the serving-profile fallback for getManagementProfile/fetchJSON, authedFetch going through withManagementProfile, and initialProfileScope's URL > bootstrap > serving precedence. Every new assertion was proven red against the PR's own previous head (ac3d794) or, for the scoping introduced there, against the pre-PR base.
This commit is contained in:
@@ -1,17 +1,47 @@
|
||||
"""One backend serves every profile, so a REST handler that reads ``get_hermes_home()``
|
||||
directly mutates the LAUNCH profile's data no matter which profile the request named.
|
||||
|
||||
These pin the two halves of the contract for the destructive routes:
|
||||
* a named profile is the one that gets wiped, and the launch profile survives;
|
||||
* an UNNAMED profile is refused (400) while several profiles are served, and still
|
||||
means the launch profile on a genuinely single-profile host.
|
||||
These pin both halves of the contract:
|
||||
|
||||
* a named profile is the one that gets wiped / written / spawned against, and the launch
|
||||
profile survives untouched;
|
||||
* an UNNAMED profile is refused (400) on a destructive or privileged route while several
|
||||
profiles are served, and still means the launch profile on a genuinely single-profile host.
|
||||
|
||||
Multi-profile hosting is NOT faked here. ``destructive_profile`` asks
|
||||
``agent.secret_scope.is_multiplex_active()``, and that flag is only ever set by the boot
|
||||
wiring (``activate_multi_profile_hosting_eagerly``) or by the lazy backstop inside
|
||||
``_config_profile_scope``. Patching the predicate would leave exactly that wiring — the
|
||||
thing the whole 400 branch rests on — untested, so the tests drive the real path and the
|
||||
autouse fixture below puts the process-global flag back (activation is one-way per process).
|
||||
"""
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import agent.secret_scope as _secret_scope
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _multiplex_state_is_per_test():
|
||||
"""Multi-profile activation is a one-way PROCESS-global flip; contain it to one test.
|
||||
|
||||
Entered single-profile (so a leak from an earlier module cannot make an unnamed
|
||||
request 400 by accident) and restored exactly as found, including the frozen launch
|
||||
env snapshot activation captures.
|
||||
"""
|
||||
import agent.secret_scope as secret_scope
|
||||
from tui_gateway import launch_profile_policy
|
||||
|
||||
was_active = secret_scope.is_multiplex_active()
|
||||
snapshot = launch_profile_policy._snapshot
|
||||
secret_scope.set_multiplex_active(False)
|
||||
launch_profile_policy._snapshot = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
secret_scope.set_multiplex_active(was_active)
|
||||
launch_profile_policy._snapshot = snapshot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -29,9 +59,15 @@ def homes(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
(home / "memories" / "USER.md").write_text(f"user of {home.name}\n", encoding="utf-8")
|
||||
(home / "webhook_subscriptions.json").write_text(
|
||||
json.dumps({"alerts": {"secret": "s", "events": []}}), encoding="utf-8")
|
||||
(home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"hooks": {"PreToolUse": [{"command": "/bin/true"}]}}), encoding="utf-8")
|
||||
(beta / ".env").write_text("", encoding="utf-8")
|
||||
(home / "config.yaml").write_text(yaml.safe_dump({
|
||||
"hooks": {"pre_tool_call": [{"command": "/bin/true"}]},
|
||||
# A per-home marker every scoped READ path can be identified by.
|
||||
"proxy": {"label": home.name},
|
||||
}), encoding="utf-8")
|
||||
(home / ".update_check").write_text("cached\n", encoding="utf-8")
|
||||
# Non-empty: an empty ``.env`` is the crashed-``profile create`` shell that
|
||||
# ``named_profile_has_servable_identity`` deliberately refuses to count as a tenant.
|
||||
(beta / ".env").write_text("BETA=1\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: launch_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
@@ -54,56 +90,370 @@ def client(monkeypatch, homes):
|
||||
return c
|
||||
|
||||
|
||||
class _StubDB:
|
||||
"""Just enough session store for the delete/prune bodies; records nothing itself."""
|
||||
|
||||
def delete_sessions(self, ids):
|
||||
return len(ids)
|
||||
|
||||
def delete_empty_sessions(self):
|
||||
return 1
|
||||
|
||||
def count_open_prune_matches(self, **_filters):
|
||||
return 0
|
||||
|
||||
def list_prune_candidates(self, **_filters):
|
||||
return []
|
||||
|
||||
def prune_sessions(self, **_kwargs):
|
||||
return 1
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seams(monkeypatch):
|
||||
"""Record the three off-process effects these routes have, and neutralise them.
|
||||
|
||||
``spawn`` = the argv of every backgrounded ``hermes`` action, ``db`` = the profile every
|
||||
session-store open names, ``pool_home`` = the home the credential-pool body resolves.
|
||||
A route that reaches any of them after a 400 shows up as a non-empty list.
|
||||
"""
|
||||
import agent.credential_pool as credential_pool
|
||||
import agent.credential_sources as credential_sources
|
||||
from hermes_cli import web_server_gateway, web_server_sessions
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
record = {"spawn": [], "db": [], "pool_home": []}
|
||||
|
||||
class _Proc:
|
||||
pid = 4242
|
||||
|
||||
def _spawn(argv, name):
|
||||
record["spawn"].append((name, list(argv)))
|
||||
return _Proc()
|
||||
|
||||
def _open_db(profile, *, read_only):
|
||||
record["db"].append(profile)
|
||||
return _StubDB()
|
||||
|
||||
class _Pool:
|
||||
def remove_index(self, _index):
|
||||
record["pool_home"].append(str(get_hermes_home()))
|
||||
return type("_Entry", (), {"source": ""})()
|
||||
|
||||
def entries(self):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(web_server_gateway, "_spawn_hermes_action", _spawn)
|
||||
monkeypatch.setattr(web_server_sessions, "_open_session_db_for_profile", _open_db)
|
||||
monkeypatch.setattr(credential_pool, "load_pool", lambda _provider: _Pool())
|
||||
monkeypatch.setattr(credential_sources, "find_removal_step", lambda *_a: None)
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multiplexed(homes):
|
||||
"""Arm the guard the way a real two-profile host does: the BOOT probe.
|
||||
|
||||
``activate_multi_profile_hosting_eagerly`` enumerates this host's servable profile
|
||||
homes and flips ``set_multiplex_active`` itself; asserting it returned True is what
|
||||
proves the wiring — not just the flag — is what refuses the unnamed requests below.
|
||||
"""
|
||||
from agent.secret_scope import is_multiplex_active
|
||||
from tui_gateway.launch_profile_policy import activate_multi_profile_hosting_eagerly
|
||||
|
||||
assert activate_multi_profile_hosting_eagerly() is True, "boot probe did not see two homes"
|
||||
assert is_multiplex_active()
|
||||
|
||||
|
||||
def _cfg(home):
|
||||
return yaml.safe_load((home / "config.yaml").read_text()) or {}
|
||||
|
||||
|
||||
def _hooks(home):
|
||||
return (yaml.safe_load((home / "config.yaml").read_text()) or {}).get("hooks") or {}
|
||||
return _cfg(home).get("hooks") or {}
|
||||
|
||||
|
||||
# (call, what proves the named profile was hit, what proves the other was not)
|
||||
def _zip(tmp_path):
|
||||
path = tmp_path / "backup.zip"
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("manifest.json", "{}")
|
||||
return path
|
||||
|
||||
|
||||
def _q(profile):
|
||||
"""``?profile=`` fragment, or nothing at all when the request names no profile."""
|
||||
return f"?profile={profile}" if profile else ""
|
||||
|
||||
|
||||
def _body_profile(profile):
|
||||
"""The session family takes its profile in the BODY, so an unnamed request omits the key."""
|
||||
return {"profile": profile} if profile else {}
|
||||
|
||||
|
||||
# Every destructive/privileged route, as a call taking (client, profile-name-or-"", tmp_path).
|
||||
DESTRUCTIVE = {
|
||||
"memory-reset": (
|
||||
lambda c, q: c.post(f"/api/memory/reset{q}", json={"target": "all"}),
|
||||
lambda home: not (home / "memories" / "MEMORY.md").exists(),
|
||||
),
|
||||
"webhook-delete": (
|
||||
lambda c, q: c.delete(f"/api/webhooks/alerts{q}"),
|
||||
lambda home: "alerts" not in json.loads((home / "webhook_subscriptions.json").read_text()),
|
||||
),
|
||||
"hook-delete": (
|
||||
lambda c, q: c.request("DELETE", f"/api/ops/hooks{q}",
|
||||
json={"event": "PreToolUse", "command": "/bin/true"}),
|
||||
lambda home: not _hooks(home),
|
||||
),
|
||||
"memory-reset": lambda c, p, t: c.post(f"/api/memory/reset{_q(p)}", json={"target": "all"}),
|
||||
"webhook-delete": lambda c, p, t: c.delete(f"/api/webhooks/alerts{_q(p)}"),
|
||||
"hook-delete": lambda c, p, t: c.request(
|
||||
"DELETE", f"/api/ops/hooks{_q(p)}", json={"event": "pre_tool_call", "command": "/bin/true"}),
|
||||
"hook-create": lambda c, p, t: c.post(
|
||||
f"/api/ops/hooks{_q(p)}", json={"event": "pre_tool_call", "command": "/bin/armed"}),
|
||||
"checkpoints-prune": lambda c, p, t: c.post(f"/api/ops/checkpoints/prune{_q(p)}"),
|
||||
"import": lambda c, p, t: c.post(f"/api/ops/import{_q(p)}", json={"archive": str(_zip(t))}),
|
||||
"import-upload": lambda c, p, t: c.post(
|
||||
f"/api/ops/import-upload{_q(p)}",
|
||||
files={"file": ("backup.zip", _zip(t).read_bytes(), "application/zip")}),
|
||||
"credentials-pool-delete": lambda c, p, t: c.delete(f"/api/credentials/pool/anthropic/0{_q(p)}"),
|
||||
"curator-run": lambda c, p, t: c.post(f"/api/curator/run{_q(p)}"),
|
||||
"sessions-prune": lambda c, p, t: c.post(
|
||||
"/api/sessions/prune", json={"dry_run": False, **_body_profile(p)}),
|
||||
"sessions-empty": lambda c, p, t: c.delete(f"/api/sessions/empty{_q(p)}"),
|
||||
"sessions-bulk-delete": lambda c, p, t: c.post(
|
||||
"/api/sessions/bulk-delete", json={"ids": ["abc"], **_body_profile(p)}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", sorted(DESTRUCTIVE))
|
||||
def test_destructive_route_hits_the_named_profile_only(client, homes, route):
|
||||
call, gone = DESTRUCTIVE[route]
|
||||
resp = call(client, "?profile=worker_beta")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert gone(homes["worker_beta"]), f"{route} did not act on worker_beta"
|
||||
assert not gone(homes["launch"]), f"{route} also hit the launch profile"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", sorted(DESTRUCTIVE))
|
||||
def test_destructive_route_refuses_an_unnamed_profile_while_multiplexing(
|
||||
client, homes, monkeypatch, route
|
||||
client, homes, seams, multiplexed, tmp_path, route
|
||||
):
|
||||
call, gone = DESTRUCTIVE[route]
|
||||
monkeypatch.setattr(_secret_scope, "is_multiplex_active", lambda: True)
|
||||
resp = call(client, "")
|
||||
"""No profile named + several served = refused, and provably nothing happened."""
|
||||
resp = DESTRUCTIVE[route](client, "", tmp_path)
|
||||
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "explicit profile" in resp.json()["detail"]
|
||||
assert not gone(homes["launch"]), f"{route} mutated the launch profile despite the 400"
|
||||
assert not gone(homes["worker_beta"])
|
||||
assert seams == {"spawn": [], "db": [], "pool_home": []}, f"{route} reached a backend anyway"
|
||||
for home in homes.values():
|
||||
assert (home / "memories" / "MEMORY.md").exists()
|
||||
assert "alerts" in json.loads((home / "webhook_subscriptions.json").read_text())
|
||||
assert _hooks(home) == {"pre_tool_call": [{"command": "/bin/true"}]}
|
||||
|
||||
|
||||
# Routes whose effect is a file inside the target home: (call, "did it happen here?").
|
||||
FILE_EFFECTS = {
|
||||
"memory-reset": (DESTRUCTIVE["memory-reset"],
|
||||
lambda home: not (home / "memories" / "MEMORY.md").exists()),
|
||||
"webhook-delete": (DESTRUCTIVE["webhook-delete"],
|
||||
lambda home: "alerts" not in json.loads((home / "webhook_subscriptions.json").read_text())),
|
||||
"hook-delete": (DESTRUCTIVE["hook-delete"], lambda home: not _hooks(home)),
|
||||
"hook-create": (DESTRUCTIVE["hook-create"],
|
||||
lambda home: any(e.get("command") == "/bin/armed"
|
||||
for e in _hooks(home).get("pre_tool_call", []))),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", sorted(FILE_EFFECTS))
|
||||
def test_named_profile_is_the_only_one_touched(client, homes, seams, tmp_path, route):
|
||||
call, happened = FILE_EFFECTS[route]
|
||||
resp = call(client, "worker_beta", tmp_path)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert happened(homes["worker_beta"]), f"{route} did not act on worker_beta"
|
||||
assert not happened(homes["launch"]), f"{route} also hit the launch profile"
|
||||
|
||||
|
||||
# Routes whose effect is a backgrounded ``hermes`` subprocess: the profile must reach its argv,
|
||||
# because nothing else in that process knows which home the request meant.
|
||||
SPAWNING = {
|
||||
"checkpoints-prune": DESTRUCTIVE["checkpoints-prune"],
|
||||
"curator-run": DESTRUCTIVE["curator-run"],
|
||||
"import": DESTRUCTIVE["import"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", sorted(SPAWNING))
|
||||
def test_spawned_action_carries_the_named_profile(client, homes, seams, tmp_path, route):
|
||||
resp = SPAWNING[route](client, "worker_beta", tmp_path)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert [argv[:2] for _name, argv in seams["spawn"]] == [["-p", "worker_beta"]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["sessions-prune", "sessions-empty", "sessions-bulk-delete"])
|
||||
def test_session_route_opens_the_named_profiles_store(client, homes, seams, tmp_path, route):
|
||||
resp = DESTRUCTIVE[route](client, "worker_beta", tmp_path)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert seams["db"] == ["worker_beta"]
|
||||
|
||||
|
||||
def test_session_prune_dry_run_still_works_unnamed_while_multiplexing(client, seams, multiplexed):
|
||||
"""A preview deletes nothing, so the confirm dialog must keep working without a profile."""
|
||||
resp = client.post("/api/sessions/prune", json={"dry_run": True})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["removed"] == 0
|
||||
assert seams["db"] == [None]
|
||||
|
||||
|
||||
def test_credential_pool_delete_runs_in_the_named_profiles_home(client, homes, seams):
|
||||
resp = client.delete("/api/credentials/pool/anthropic/0?profile=worker_beta")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert seams["pool_home"] == [str(homes["worker_beta"])]
|
||||
|
||||
|
||||
# --- activation: the wiring the 400 branch depends on -------------------------------
|
||||
|
||||
|
||||
def test_a_request_for_another_profile_arms_the_guard(client, homes):
|
||||
"""The lazy backstop: the first cross-profile request is itself the activation."""
|
||||
from agent.secret_scope import is_multiplex_active
|
||||
|
||||
assert not is_multiplex_active()
|
||||
assert client.get("/api/memory?profile=worker_beta").status_code == 200
|
||||
assert is_multiplex_active()
|
||||
|
||||
assert client.post("/api/memory/reset", json={"target": "all"}).status_code == 400
|
||||
assert (homes["launch"] / "memories" / "MEMORY.md").exists()
|
||||
|
||||
|
||||
def test_unnamed_profile_still_means_the_launch_profile_on_a_single_profile_host(
|
||||
client, homes, monkeypatch
|
||||
client, homes, monkeypatch, tmp_path
|
||||
):
|
||||
"""A plain ``hermes serve`` has nothing to confuse: `curl` with no profile is unchanged."""
|
||||
monkeypatch.setattr(_secret_scope, "is_multiplex_active", lambda: False)
|
||||
from agent.secret_scope import is_multiplex_active
|
||||
from hermes_cli import profiles
|
||||
from tui_gateway.launch_profile_policy import activate_multi_profile_hosting_eagerly
|
||||
|
||||
empty_root = tmp_path / "no-named-profiles"
|
||||
empty_root.mkdir()
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: empty_root)
|
||||
assert activate_multi_profile_hosting_eagerly() is False
|
||||
assert not is_multiplex_active()
|
||||
|
||||
resp = client.post("/api/memory/reset", json={"target": "all"})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert not (homes["launch"] / "memories" / "MEMORY.md").exists()
|
||||
assert (homes["worker_beta"] / "memories" / "MEMORY.md").exists()
|
||||
|
||||
|
||||
# --- config writes that are scoped but not destructive ------------------------------
|
||||
|
||||
|
||||
def test_plugin_providers_writes_the_named_profiles_config(client, homes):
|
||||
resp = client.put("/api/dashboard/plugin-providers?profile=worker_beta",
|
||||
json={"context_engine": "compaction-v2"})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _cfg(homes["worker_beta"]).get("context") == {"engine": "compaction-v2"}
|
||||
assert "context" not in _cfg(homes["launch"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def readiness_only_in_beta(homes, monkeypatch):
|
||||
"""A memory provider that is ready in worker_beta and nowhere else.
|
||||
|
||||
Readiness resolves through ``load_config()``, so a readiness check that runs OUTSIDE the
|
||||
request's scope answers about the launch profile: it refuses a provider the target has
|
||||
configured, and — the dangerous direction — accepts one only the launch profile has and
|
||||
writes it into the target as a broken setting.
|
||||
"""
|
||||
from hermes_cli import web_server_memory
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
def _statuses():
|
||||
ready = get_hermes_home().resolve() == homes["worker_beta"].resolve()
|
||||
return [{"name": "mem0", "status": "ready" if ready else "not_configured"}]
|
||||
|
||||
monkeypatch.setattr(web_server_memory, "_discover_memory_provider_statuses", _statuses)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("call", [
|
||||
pytest.param(lambda c: c.put("/api/memory/provider?profile=worker_beta", json={"provider": "mem0"}),
|
||||
id="memory-provider"),
|
||||
pytest.param(lambda c: c.put("/api/dashboard/plugin-providers?profile=worker_beta",
|
||||
json={"memory_provider": "mem0"}),
|
||||
id="plugin-providers"),
|
||||
])
|
||||
def test_memory_provider_readiness_is_judged_in_the_profile_being_written(
|
||||
client, homes, readiness_only_in_beta, call
|
||||
):
|
||||
resp = call(client)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _cfg(homes["worker_beta"])["memory"]["provider"] == "mem0"
|
||||
assert "memory" not in _cfg(homes["launch"])
|
||||
|
||||
|
||||
def test_local_models_quickstart_activates_into_the_named_profile(client, homes, monkeypatch):
|
||||
"""Quickstart is ``activate`` plus a download; its config writes must follow ``?profile=``."""
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_cli.web_routers import local_models as lm
|
||||
|
||||
entry = type("_Entry", (), {"id": "m1", "display_name": "M One", "min_engine": None})()
|
||||
variant = type("_Variant", (), {"model_id": "m1-q4"})()
|
||||
seen = []
|
||||
|
||||
monkeypatch.setattr(lm.hardware, "probe_budget", lambda **_kw: None)
|
||||
monkeypatch.setattr(lm, "_quickstart_target", lambda _body, _budget: (entry, variant))
|
||||
monkeypatch.setattr(lm, "_runtime_target", lambda *_a: ("b1", "cpu"))
|
||||
monkeypatch.setattr(lm.binaries, "installed_tags", lambda: ["b1"])
|
||||
monkeypatch.setattr(lm.bootstrap, "staged_model_ids", lambda: {"m1-q4"})
|
||||
monkeypatch.setattr(lm, "_set_runtime_enabled", lambda _on: (lambda: None))
|
||||
monkeypatch.setattr(lm, "_ensure_server", lambda *_a, **_k: None)
|
||||
monkeypatch.setattr(lm, "_assign_default", lambda *_a: seen.append(str(get_hermes_home())))
|
||||
# Run the job body inline; the real spawner's ``on_exit`` is what frees the quickstart lock.
|
||||
monkeypatch.setattr(lm, "_spawn_job", lambda job, name, body, **kw: (body(), kw["on_exit"]()))
|
||||
|
||||
resp = client.post("/api/local-models/quickstart?profile=worker_beta", json={})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert seen == [str(homes["worker_beta"])]
|
||||
|
||||
|
||||
# --- the read/side-effect routes scoped in the same sweep ---------------------------
|
||||
|
||||
|
||||
def test_curator_pause_writes_the_named_profiles_state(client, homes):
|
||||
resp = client.put("/api/curator/paused?profile=worker_beta", json={"paused": True})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert json.loads((homes["worker_beta"] / "skills" / ".curator_state").read_text())["paused"] is True
|
||||
assert not (homes["launch"] / "skills" / ".curator_state").exists()
|
||||
|
||||
|
||||
def test_forced_update_check_busts_the_named_profiles_cache(client, homes, monkeypatch):
|
||||
from hermes_cli import banner
|
||||
from hermes_cli.web_routers import actions
|
||||
|
||||
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
|
||||
monkeypatch.setattr(actions, "_dashboard_local_update_managed_externally", lambda: False)
|
||||
monkeypatch.setattr(actions, "detect_install_method", lambda _root: "git")
|
||||
|
||||
resp = client.get("/api/hermes/update/check?force=true&profile=worker_beta")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert not (homes["worker_beta"] / ".update_check").exists()
|
||||
assert (homes["launch"] / ".update_check").exists()
|
||||
|
||||
|
||||
def test_egress_status_reads_the_named_profiles_config(client, homes, monkeypatch):
|
||||
from hermes_cli import proxy_cli
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
monkeypatch.setattr(proxy_cli, "format_status_text",
|
||||
lambda **_kw: str((load_config().get("proxy") or {}).get("label")))
|
||||
|
||||
resp = client.get("/api/egress/status?profile=worker_beta")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["text"] == "worker_beta"
|
||||
|
||||
|
||||
def test_memory_provider_setup_runs_in_the_named_profiles_home(client, homes, monkeypatch):
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_cli.web_routers import memory_providers as mp
|
||||
|
||||
monkeypatch.setattr(mp, "_memory_provider_manifest", lambda _name: {"name": "mem0"})
|
||||
monkeypatch.setattr(mp, "_load_memory_provider", lambda _name: None)
|
||||
monkeypatch.setattr(mp, "_install_memory_provider_setup",
|
||||
lambda name: {"ok": True, "provider": name, "home": str(get_hermes_home())})
|
||||
|
||||
resp = client.post("/api/memory/providers/mem0/setup?profile=worker_beta", json={})
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["home"] == str(homes["worker_beta"])
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { api, fetchJSON, setManagementProfile } from "./api";
|
||||
import {
|
||||
api,
|
||||
authedFetch,
|
||||
fetchJSON,
|
||||
getManagementProfile,
|
||||
setManagementProfile,
|
||||
} from "./api";
|
||||
|
||||
const reloadMocks = vi.hoisted(() => ({
|
||||
attemptDashboardTokenReloadOnce: vi.fn(() => false),
|
||||
@@ -119,6 +125,100 @@ describe("api.getModelOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("management profile scope", () => {
|
||||
// Every family whose routes write into a named profile's home must carry the
|
||||
// scope; an unprofiled request 400s on a host that merely HAS a second profile.
|
||||
it.each([
|
||||
"/api/credentials/pool/anthropic/0",
|
||||
"/api/dashboard/plugin-providers",
|
||||
"/api/model/recommended-default",
|
||||
"/api/local-models",
|
||||
"/api/ops/restart",
|
||||
])("scopes %s to the selected management profile", async (path) => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetchMock = jsonFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setManagementProfile("worker");
|
||||
|
||||
await fetchJSON(path, { method: "POST" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`${path}?profile=worker`);
|
||||
});
|
||||
|
||||
it("leaves endpoints outside the scoped families alone", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetchMock = jsonFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setManagementProfile("worker");
|
||||
|
||||
await fetchJSON("/api/sessions");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/sessions");
|
||||
});
|
||||
|
||||
it("falls back to the profile this backend serves when nothing is selected", async () => {
|
||||
vi.stubGlobal("window", { __HERMES_DASHBOARD_PROFILE__: "served" });
|
||||
const fetchMock = jsonFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setManagementProfile("");
|
||||
|
||||
expect(getManagementProfile()).toBe("served");
|
||||
|
||||
await fetchJSON("/api/credentials/pool/anthropic/0", { method: "DELETE" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
"/api/credentials/pool/anthropic/0?profile=served",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the selected profile ahead of the serving profile", async () => {
|
||||
vi.stubGlobal("window", { __HERMES_DASHBOARD_PROFILE__: "served" });
|
||||
const fetchMock = jsonFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setManagementProfile("worker");
|
||||
|
||||
expect(getManagementProfile()).toBe("worker");
|
||||
|
||||
await fetchJSON("/api/credentials/pool/anthropic/0", { method: "DELETE" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(
|
||||
"/api/credentials/pool/anthropic/0?profile=worker",
|
||||
);
|
||||
});
|
||||
|
||||
it("names no profile at all when neither a selection nor a serving profile exists", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetchMock = jsonFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setManagementProfile("");
|
||||
|
||||
expect(getManagementProfile()).toBe("");
|
||||
|
||||
await fetchJSON("/api/credentials/pool/anthropic/0", { method: "DELETE" });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/credentials/pool/anthropic/0");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/api/ops/backup/download", "/api/ops/backup/download?profile=worker"],
|
||||
["/api/ops/backup/download?full=1", "/api/ops/backup/download?full=1&profile=worker"],
|
||||
["/api/ops/backup/download?profile=other", "/api/ops/backup/download?profile=other"],
|
||||
["/api/sessions/abc/export", "/api/sessions/abc/export"],
|
||||
])(
|
||||
"authedFetch resolves %s through the same management scope",
|
||||
async (path, expected) => {
|
||||
vi.stubGlobal("window", {});
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => new Response("binary"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setManagementProfile("worker");
|
||||
|
||||
await authedFetch(path);
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("api OAuth helpers", () => {
|
||||
it("starts OAuth login in gated mode without requiring an injected session token", async () => {
|
||||
vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
dashboardServingProfile,
|
||||
initialProfileScope,
|
||||
shouldAdoptActiveProfile,
|
||||
} from "./profile-bootstrap";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("initialProfileScope", () => {
|
||||
it("inherits the dashboard bootstrap profile when the URL omits profile", () => {
|
||||
expect(initialProfileScope(new URLSearchParams("resume=session-1"), "worker_x"))
|
||||
@@ -38,3 +43,39 @@ describe("initialProfileScope", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboardServingProfile", () => {
|
||||
it("names no profile when there is no window at all", () => {
|
||||
expect(dashboardServingProfile()).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an injected serving profile", { __HERMES_DASHBOARD_PROFILE__: "served" }, "served"],
|
||||
["a window without one", {}, ""],
|
||||
])("reports %s", (_label, windowStub, expected) => {
|
||||
vi.stubGlobal("window", windowStub);
|
||||
expect(dashboardServingProfile()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialProfileScope precedence", () => {
|
||||
// URL > bootstrap > serving. The serving profile is the LAST resort: it says
|
||||
// out loud what an unnamed request already meant, so it must never override a
|
||||
// scope the URL or the bootstrap payload already named.
|
||||
it.each([
|
||||
["the URL profile outranks bootstrap and serving", "profile=url", "boot", "served", "url"],
|
||||
["an explicit empty URL profile still outranks both", "profile=", "boot", "served", ""],
|
||||
["the bootstrap profile outranks the serving profile", "resume=s1", "boot", "served", "boot"],
|
||||
["the serving profile is used when nothing else names one", "resume=s1", "", "served", "served"],
|
||||
["no scope is invented when nothing names one", "resume=s1", "", "", ""],
|
||||
])("%s", (_label, query, bootstrap, serving, expected) => {
|
||||
expect(
|
||||
initialProfileScope(new URLSearchParams(query), bootstrap, serving),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("defaults the serving profile to the one this backend injected", () => {
|
||||
vi.stubGlobal("window", { __HERMES_DASHBOARD_PROFILE__: "served" });
|
||||
expect(initialProfileScope(new URLSearchParams("resume=s1"), "")).toBe("served");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user