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:
teknium1
2026-09-21 09:49:02 -07:00
committed by Teknium
parent 2e497bead8
commit 0ddeaf9334
3 changed files with 534 additions and 43 deletions

View File

@@ -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"])

View File

@@ -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 });

View File

@@ -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");
});
});