An install from v2026.3.12 ships `.env` with `LLM_MODEL` set, because that release's template wrote it. The upgrade runs config migration 12 -> 13, which clears that dead var -- and the user-state verifier reported the change as the upgrade modifying the user's own state, failing the leg. The verifier exists to catch an upgrade taking state away; a var the CURRENT tree retires is not that. The retired set is parsed out of `hermes_cli/config_migrations.py` (the `for dead in (...): save_env_value(dead, "")` shape) rather than restated here, so it cannot drift from the tree, and an unreadable source retires nothing -- every .env change stays fatal. Tolerance is deliberately narrow: only keys the tree retires, only when the upgrade EMPTIED them, and only when no key was added or removed. Clearing a live key, or deleting a retired one outright, still fails.
494 lines
20 KiB
Python
494 lines
20 KiB
Python
"""Unit tests for the user-state upgrade-preservation verifier.
|
|
|
|
tests/install/e2e-assets/verify-user-state.py is the standalone hook the
|
|
install/update E2E drivers call around a real upgrade. These tests exercise it
|
|
against a real temp HERMES_HOME (real files, real sqlite databases) — no
|
|
source-reading, no filesystem mocks.
|
|
|
|
The contract under test: an upgrade may ADD state and may rewrite config.yaml
|
|
(config migration) and the bundled skills tree (skills sync), but it may not
|
|
delete or modify the user's own durable state, and it may not shrink state.db.
|
|
plugins/** is deliberately not owned here — verify-plugin-preservation.py owns
|
|
it — so this tool must not fail on it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
VERIFIER = os.path.join(_HERE, "..", "install", "e2e-assets", "verify-user-state.py")
|
|
|
|
_spec = importlib.util.spec_from_file_location("verify_user_state", VERIFIER)
|
|
vus = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(vus)
|
|
|
|
|
|
def _make_db(path, sessions=2, messages=3):
|
|
con = sqlite3.connect(str(path))
|
|
con.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY)")
|
|
con.execute("CREATE TABLE IF NOT EXISTS messages (id TEXT)")
|
|
con.executemany("INSERT OR REPLACE INTO sessions VALUES (?)",
|
|
[(f"s{i}",) for i in range(sessions)])
|
|
con.executemany("INSERT INTO messages VALUES (?)",
|
|
[(f"m{i}",) for i in range(messages)])
|
|
con.commit()
|
|
con.close()
|
|
|
|
|
|
def _home(tmp_path):
|
|
home = tmp_path / "home"
|
|
for rel in ("memories", "cron", "sessions", "skills/foo", "skills/.archive/mine",
|
|
"plugins/demo", "photon/sidecar", "profiles/work"):
|
|
(home / rel).mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("timezone: utc\n", encoding="utf-8")
|
|
(home / ".env").write_text("NOUS_API_KEY=xxx\n", encoding="utf-8")
|
|
(home / "auth.json").write_text('{"tokens":{}}\n', encoding="utf-8")
|
|
(home / "memories" / "note.md").write_text("remember\n", encoding="utf-8")
|
|
(home / "cron" / "jobs.json").write_text('{"jobs":[]}\n', encoding="utf-8")
|
|
(home / "sessions" / "2026.jsonl").write_text('{"t":1}\n', encoding="utf-8")
|
|
(home / "skills" / "foo" / "SKILL.md").write_text("bundled\n", encoding="utf-8")
|
|
(home / "skills" / ".archive" / "mine" / "SKILL.md").write_text("mine\n", encoding="utf-8")
|
|
(home / "plugins" / "demo" / "plugin.yaml").write_text("name: demo\n", encoding="utf-8")
|
|
(home / "photon" / "sidecar" / "package-lock.json").write_text("{}\n", encoding="utf-8")
|
|
(home / "profiles" / "work" / "config.yaml").write_text("x: 1\n", encoding="utf-8")
|
|
_make_db(home / "state.db")
|
|
return home
|
|
|
|
|
|
def _snapshot(home):
|
|
return vus.snapshot_home(str(home))
|
|
|
|
|
|
def _verify(home, snap):
|
|
return vus.verify_home(str(home), snap)
|
|
|
|
|
|
# --- shape ------------------------------------------------------------------
|
|
|
|
def test_snapshot_records_rows_not_bytes_for_state_db(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
record = snap["entries"]["state.db"]
|
|
assert record["kind"] == "file"
|
|
assert record["rows"] == {"sessions": 2, "messages": 3}
|
|
assert "sha256" not in record, "a live db must be judged by rows, not bytes"
|
|
|
|
|
|
def test_snapshot_is_read_only(tmp_path):
|
|
home = _home(tmp_path)
|
|
before = sorted(os.path.relpath(os.path.join(dp, n), home)
|
|
for dp, _, ns in os.walk(home) for n in ns)
|
|
snap = _snapshot(home)
|
|
after = sorted(os.path.relpath(os.path.join(dp, n), home)
|
|
for dp, _, ns in os.walk(home) for n in ns)
|
|
assert before == after
|
|
assert snap["entries"], "a used home must produce judged entries"
|
|
|
|
|
|
def test_plugins_and_bundled_skills_are_not_judged(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
assert not [k for k in snap["entries"] if k.startswith("plugins/")], \
|
|
"plugins/** is owned by verify-plugin-preservation.py"
|
|
assert "skills/foo/SKILL.md" in snap["advisory"]
|
|
assert "skills/foo/SKILL.md" not in snap["entries"]
|
|
assert "skills/.archive/mine/SKILL.md" in snap["entries"], \
|
|
"the curator's archive holds USER skills and is judged"
|
|
|
|
|
|
# --- the contract -----------------------------------------------------------
|
|
|
|
def test_verify_passes_when_nothing_changed(tmp_path):
|
|
home = _home(tmp_path)
|
|
report = _verify(home, _snapshot(home))
|
|
assert report["ok"] is True
|
|
assert report["deleted"] == [] and report["modified"] == {}
|
|
|
|
|
|
def test_tolerates_added_files(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
(home / "memories" / "another.md").write_text("new\n", encoding="utf-8")
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is True, "an upgrade may add files"
|
|
assert "memories/another.md" in report["added"]
|
|
|
|
|
|
def test_fails_when_a_user_file_is_deleted(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
(home / "memories" / "note.md").unlink()
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is False
|
|
assert "memories/note.md" in report["deleted"]
|
|
|
|
|
|
def test_fails_when_env_is_modified(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
(home / ".env").write_text("CLOBBERED=1\n", encoding="utf-8")
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is False
|
|
assert ".env" in report["modified"]
|
|
|
|
|
|
def test_tolerates_config_yaml_rewrite(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
(home / "config.yaml").write_text("timezone: utc\nnew_key: 1\n", encoding="utf-8")
|
|
(home / "profiles" / "work" / "config.yaml").write_text("x: 1\ny: 2\n", encoding="utf-8")
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is True, "config migration rewrites config.yaml additively"
|
|
assert sorted(report["tolerated_modified"]) == sorted(
|
|
["config.yaml", "profiles/work/config.yaml"])
|
|
|
|
|
|
def test_fails_when_state_db_loses_rows(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
con = sqlite3.connect(str(home / "state.db"))
|
|
con.execute("DELETE FROM sessions WHERE id='s1'")
|
|
con.commit()
|
|
con.close()
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is False
|
|
assert report["rows_shrank"] == ["state.db"]
|
|
|
|
|
|
def test_tolerates_state_db_growth(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
con = sqlite3.connect(str(home / "state.db"))
|
|
con.execute("INSERT INTO sessions VALUES ('s9')")
|
|
con.commit()
|
|
con.close()
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is True, "a later turn adds rows; that is not loss"
|
|
|
|
|
|
def test_bundled_skill_resync_is_advisory_only(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
# A release adds a bundled skill and updates an existing one.
|
|
(home / "skills" / "bar").mkdir()
|
|
(home / "skills" / "bar" / "SKILL.md").write_text("new bundled\n", encoding="utf-8")
|
|
(home / "skills" / "foo" / "SKILL.md").write_text("updated bundled\n", encoding="utf-8")
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is True, "skills sync rewrites the bundled tree by design"
|
|
assert "skills/foo/SKILL.md" in report["advisory"]["modified"]
|
|
|
|
|
|
def test_bundled_skill_resync_inside_a_profile_is_advisory_only(tmp_path):
|
|
"""A second profile's bundled skills re-sync on update exactly like the
|
|
root's, so judging them would fail every leg that owns a profile."""
|
|
home = _home(tmp_path)
|
|
skills = home / "profiles" / "work" / "skills" / "foo"
|
|
skills.mkdir(parents=True)
|
|
(skills / "SKILL.md").write_text("bundled\n", encoding="utf-8")
|
|
snap = _snapshot(home)
|
|
# The update re-syncs every profile: bundled skill content moves.
|
|
(skills / "SKILL.md").write_text("updated bundled\n", encoding="utf-8")
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is True, "a per-profile skills sync is not a user-state change"
|
|
assert "profiles/work/skills/foo/SKILL.md" in report["advisory"]["modified"]
|
|
assert "profiles/work/skills/foo/SKILL.md" not in report["modified"]
|
|
|
|
|
|
def test_fails_when_a_profile_skill_archive_is_lost(tmp_path):
|
|
"""skills/.archive/** holds restorable USER skills at both roots."""
|
|
home = _home(tmp_path)
|
|
archive = home / "profiles" / "work" / "skills" / ".archive" / "mine"
|
|
archive.mkdir(parents=True)
|
|
(archive / "SKILL.md").write_text("mine\n", encoding="utf-8")
|
|
snap = _snapshot(home)
|
|
(archive / "SKILL.md").unlink()
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is False
|
|
assert "profiles/work/skills/.archive/mine/SKILL.md" in report["deleted"]
|
|
|
|
|
|
def test_fails_when_archived_user_skill_is_lost(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
(home / "skills" / ".archive" / "mine" / "SKILL.md").unlink()
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is False
|
|
assert "skills/.archive/mine/SKILL.md" in report["deleted"]
|
|
|
|
|
|
def test_fails_when_a_profile_directory_disappears(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = _snapshot(home)
|
|
(home / "profiles" / "work" / "config.yaml").unlink()
|
|
report = _verify(home, snap)
|
|
assert report["ok"] is False
|
|
assert "profiles/work/config.yaml" in report["deleted"]
|
|
|
|
|
|
# --- CLI contract -----------------------------------------------------------
|
|
|
|
def _run(args):
|
|
return subprocess.run([sys.executable, VERIFIER, *args],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def test_cli_snapshot_then_verify_round_trip(tmp_path):
|
|
home = _home(tmp_path)
|
|
snap = tmp_path / "snap.json"
|
|
report = tmp_path / "report.json"
|
|
first = _run(["snapshot", "--home", str(home), "--out", str(snap)])
|
|
assert first.returncode == 0, first.stderr
|
|
assert snap.exists()
|
|
ok = _run(["verify", "--home", str(home), "--snapshot", str(snap),
|
|
"--report", str(report)])
|
|
assert ok.returncode == 0, ok.stderr
|
|
assert json.loads(report.read_text(encoding="utf-8"))["ok"] is True
|
|
|
|
(home / "memories" / "note.md").unlink()
|
|
bad = _run(["verify", "--home", str(home), "--snapshot", str(snap)])
|
|
assert bad.returncode == 1
|
|
assert "USER-STATE PRESERVATION FAILED" in bad.stderr
|
|
|
|
|
|
def test_cli_refuses_an_empty_snapshot_as_inconclusive(tmp_path):
|
|
empty = tmp_path / "empty"
|
|
empty.mkdir()
|
|
snap = tmp_path / "snap.json"
|
|
result = _run(["snapshot", "--home", str(empty), "--out", str(snap)])
|
|
assert result.returncode == 3
|
|
assert "INCONCLUSIVE" in result.stderr
|
|
|
|
|
|
def test_cli_rejects_a_missing_home(tmp_path):
|
|
result = _run(["snapshot", "--home", str(tmp_path / "nope"),
|
|
"--out", str(tmp_path / "s.json")])
|
|
assert result.returncode == 2
|
|
|
|
|
|
def test_a_rewritten_dotenv_names_the_moved_variables_and_never_their_values(tmp_path):
|
|
"""An equal-size .env rewrite is invisible in the whole-file hash alone.
|
|
|
|
The app-driven upgrade rewrote .env at an identical byte count with a
|
|
different sha256, leaving the leg holding two hashes of a secrets file and no
|
|
lead. The report must name the variable -- and carry no value.
|
|
"""
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
(home / ".env").write_text(
|
|
"OPENAI_API_KEY=aaaa\n"
|
|
"OPENAI_BASE_URL=http://127.0.0.1:9001/v1\n"
|
|
"# a comment\n"
|
|
"export ANTHROPIC_API_KEY=cccc\n",
|
|
encoding="utf-8",
|
|
)
|
|
snap = vus.snapshot_home(str(home))
|
|
|
|
(home / ".env").write_text(
|
|
"OPENAI_API_KEY=bbbb\n"
|
|
"OPENAI_BASE_URL=http://127.0.0.1:9001/v1\n"
|
|
"# a comment\n"
|
|
"export ANTHROPIC_API_KEY=cccc\n"
|
|
"NEW_KEY=dddd\n",
|
|
encoding="utf-8",
|
|
)
|
|
report = vus.verify_home(str(home), snap)
|
|
|
|
diff = report["modified"][".env"]["key_diff"]
|
|
assert diff["keys_changed"] == ["OPENAI_API_KEY"]
|
|
assert diff["keys_added"] == ["NEW_KEY"]
|
|
assert diff["keys_removed"] == []
|
|
rendered = vus._render(report)
|
|
assert "changed=OPENAI_API_KEY" in rendered
|
|
assert "added=NEW_KEY" in rendered
|
|
# Names only: a secrets file's content must never reach the report.
|
|
for leaked in ("aaaa", "bbbb", "cccc", "dddd", "http://127.0.0.1:9001/v1"):
|
|
assert leaked not in rendered
|
|
assert leaked not in json.dumps(report)
|
|
|
|
|
|
def test_cron_ticker_stamps_move_but_user_cron_state_still_fails(tmp_path):
|
|
"""A live ticker rewrites its own liveness stamps; that is not user state.
|
|
|
|
On a cold home those files appear as additions (tolerated). Once the ticker
|
|
exists the same files register as modifications, which used to fail the user's
|
|
upgrade outward for the harness's own background process.
|
|
"""
|
|
home = tmp_path / "home"
|
|
(home / "cron").mkdir(parents=True)
|
|
(home / "profiles" / "p" / "cron").mkdir(parents=True)
|
|
(home / "cron" / "ticker_heartbeat").write_text("100\n", encoding="utf-8")
|
|
(home / "profiles" / "p" / "cron" / "ticker_last_success").write_text("100\n", encoding="utf-8")
|
|
(home / "cron" / "jobs.json").write_text('{"jobs": []}\n', encoding="utf-8")
|
|
snap = vus.snapshot_home(str(home))
|
|
|
|
(home / "cron" / "ticker_heartbeat").write_text("200\n", encoding="utf-8")
|
|
(home / "profiles" / "p" / "cron" / "ticker_last_success").write_text("200\n", encoding="utf-8")
|
|
(home / "cron" / "jobs.json").write_text('{"jobs": [1]}\n', encoding="utf-8")
|
|
report = vus.verify_home(str(home), snap)
|
|
|
|
assert set(report["modified"]) == {"cron/jobs.json"}
|
|
assert report["tolerated_modified"] == [
|
|
"cron/ticker_heartbeat", "profiles/p/cron/ticker_last_success"]
|
|
# The user's own cron DEFINITIONS are still state that must not move.
|
|
assert report["ok"] is False
|
|
|
|
|
|
def test_a_dotenv_changed_only_in_comments_is_reported_as_such(tmp_path):
|
|
"""The per-key digests ignore comments, order and blanks by design.
|
|
|
|
So a .env rewritten only in those parts reports as modified with no `variables`
|
|
line -- the real "0 deleted, 1 modified" with nothing named. The report must say
|
|
that explicitly, and still carry no values.
|
|
"""
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
(home / ".env").write_text("# one\nOPENAI_BASE_URL=http://127.0.0.1:9000/v1\n", encoding="utf-8")
|
|
snap = vus.snapshot_home(str(home))
|
|
|
|
(home / ".env").write_text("# one\n# two\nOPENAI_BASE_URL=http://127.0.0.1:9000/v1\n", encoding="utf-8")
|
|
report = vus.verify_home(str(home), snap)
|
|
|
|
assert set(report["modified"]) == {".env"}
|
|
assert report["modified"][".env"]["key_diff"]["keys_changed"] == []
|
|
rendered = vus._render(report)
|
|
assert "no key differs" in rendered
|
|
assert "http://127.0.0.1:9000/v1" not in rendered
|
|
|
|
|
|
def test_sqlite_sidecars_may_vanish_but_the_database_may_not(tmp_path):
|
|
"""A WAL/shm pair exists only while a connection is open.
|
|
|
|
A snapshot taken with one live sees them; they vanish when it closes, and the
|
|
report then reads as the upgrade DELETING user state (seen live on
|
|
cron/executions.db-shm and -wal). The database itself must still fail if lost.
|
|
"""
|
|
home = tmp_path / "home"
|
|
(home / "cron").mkdir(parents=True)
|
|
for name in ("executions.db", "executions.db-wal", "executions.db-shm"):
|
|
(home / "cron" / name).write_text(f"{name}\n", encoding="utf-8")
|
|
snap = vus.snapshot_home(str(home))
|
|
|
|
os.remove(home / "cron" / "executions.db-wal")
|
|
os.remove(home / "cron" / "executions.db-shm")
|
|
report = vus.verify_home(str(home), snap)
|
|
assert report["deleted"] == []
|
|
assert report["tolerated_deleted"] == ["cron/executions.db-shm", "cron/executions.db-wal"]
|
|
assert report["ok"] is True
|
|
|
|
os.remove(home / "cron" / "executions.db")
|
|
report = vus.verify_home(str(home), snap)
|
|
assert report["deleted"] == ["cron/executions.db"]
|
|
assert report["ok"] is False
|
|
|
|
|
|
def test_any_sqlite_database_is_judged_by_rows_not_bytes(tmp_path):
|
|
"""Live SQLite files churn bytes constantly; their contract is the rows.
|
|
|
|
state.db was already handled that way; cron/executions.db was not, so a run
|
|
writing rows mid-window would have failed the leg as a byte modification.
|
|
"""
|
|
home = tmp_path / "home"
|
|
(home / "cron").mkdir(parents=True)
|
|
db = home / "cron" / "executions.db"
|
|
with sqlite3.connect(db) as conn:
|
|
# A table the verifier actually counts (COUNTED_TABLES), not an arbitrary one.
|
|
conn.execute("create table cron_jobs (id integer primary key, at text)")
|
|
conn.execute("insert into cron_jobs (at) values ('one')")
|
|
snap = vus.snapshot_home(str(home))
|
|
|
|
with sqlite3.connect(db) as conn: # rows added: bytes move, contract holds
|
|
conn.execute("insert into cron_jobs (at) values ('two')")
|
|
report = vus.verify_home(str(home), snap)
|
|
assert report["modified"] == {}
|
|
assert report["rows_shrank"] == []
|
|
assert report["ok"] is True
|
|
|
|
with sqlite3.connect(db) as conn: # rows lost: still fatal
|
|
conn.execute("delete from cron_jobs")
|
|
report = vus.verify_home(str(home), snap)
|
|
assert report["rows_shrank"] == ["cron/executions.db"]
|
|
assert report["ok"] is False
|
|
|
|
|
|
# --- retired provider vars ---------------------------------------------------
|
|
|
|
def test_retired_env_vars_come_from_the_migration_source(tmp_path):
|
|
"""The set is read from hermes_cli/config_migrations.py, not invented here.
|
|
|
|
The 12 -> 13 migration clears LLM_MODEL/OPENAI_MODEL: the old setup wizard
|
|
wrote them and nothing reads them now. A verifier carrying its own copy of
|
|
that list would silently stop following the tree the moment it changes.
|
|
"""
|
|
assert vus.retired_env_vars() == {"LLM_MODEL", "OPENAI_MODEL"}
|
|
# Unreadable or unparseable source retires nothing, so every .env change
|
|
# stays fatal rather than quietly becoming tolerated.
|
|
assert vus.retired_env_vars(tmp_path / "absent.py") == frozenset()
|
|
broken = tmp_path / "broken.py"
|
|
broken.write_text("def (:\n", encoding="utf-8")
|
|
assert vus.retired_env_vars(broken) == frozenset()
|
|
|
|
|
|
def test_a_retired_var_the_upgrade_empties_is_tolerated_and_named(tmp_path):
|
|
"""An old release's .env holds LLM_MODEL; the migration empties it.
|
|
|
|
This verifier exists to catch an upgrade taking the user's state away. A key
|
|
the tree itself retires is not that, so it is reported -- by name, never by
|
|
value -- instead of failing the leg.
|
|
"""
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
(home / ".env").write_text(
|
|
"LLM_MODEL=anthropic/claude-opus-4.6\nOPENROUTER_API_KEY=secret-value\n",
|
|
encoding="utf-8",
|
|
)
|
|
snap = vus.snapshot_home(str(home))
|
|
# Exactly the migration's write: same key, cleared value.
|
|
(home / ".env").write_text(
|
|
"LLM_MODEL=\nOPENROUTER_API_KEY=secret-value\n", encoding="utf-8"
|
|
)
|
|
|
|
report = vus.verify_home(str(home), snap)
|
|
|
|
assert report["ok"] is True, vus._render(report)
|
|
assert report["retired_env_cleared"] == {".env": ["LLM_MODEL"]}
|
|
rendered = vus._render(report)
|
|
assert "tolerated (retired var cleared: LLM_MODEL) .env" in rendered
|
|
assert "secret-value" not in rendered
|
|
assert "claude-opus" not in rendered
|
|
|
|
|
|
def test_a_live_key_cleared_by_an_upgrade_still_fails(tmp_path):
|
|
"""The exception is retired keys only -- emptying a real key is still loss."""
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
(home / ".env").write_text("OPENROUTER_API_KEY=secret-value\n", encoding="utf-8")
|
|
snap = vus.snapshot_home(str(home))
|
|
(home / ".env").write_text("OPENROUTER_API_KEY=\n", encoding="utf-8")
|
|
|
|
report = vus.verify_home(str(home), snap)
|
|
|
|
assert report["ok"] is False
|
|
assert report["modified"][".env"]["key_diff"]["keys_changed"] == ["OPENROUTER_API_KEY"]
|
|
|
|
|
|
def test_a_retired_var_removed_outright_still_fails(tmp_path):
|
|
"""Clearing a retired key is the migration; deleting the line is not it."""
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
(home / ".env").write_text("LLM_MODEL=anthropic/claude-opus-4.6\n", encoding="utf-8")
|
|
snap = vus.snapshot_home(str(home))
|
|
(home / ".env").write_text("", encoding="utf-8")
|
|
|
|
report = vus.verify_home(str(home), snap)
|
|
|
|
assert report["ok"] is False
|
|
assert report["modified"][".env"]["key_diff"]["keys_removed"] == ["LLM_MODEL"]
|