diff --git a/tests/hermes_cli/test_web_routers_destructive_profile_scope.py b/tests/hermes_cli/test_web_routers_destructive_profile_scope.py index 42130ff34f..be301cb6eb 100644 --- a/tests/hermes_cli/test_web_routers_destructive_profile_scope.py +++ b/tests/hermes_cli/test_web_routers_destructive_profile_scope.py @@ -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"]) diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 2524cc302f..3a97b6131b 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -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(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 }); diff --git a/web/src/lib/profile-bootstrap.test.ts b/web/src/lib/profile-bootstrap.test.ts index 583af2e9f5..90d46e95db 100644 --- a/web/src/lib/profile-bootstrap.test.ts +++ b/web/src/lib/profile-bootstrap.test.ts @@ -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"); + }); +});