Files
hermes-webui/tests/test_issue5379_cron_project_optin.py
T

401 lines
15 KiB
Python

"""Regression tests for #5379: gate 'Cron Jobs' project auto-creation behind
project opt-in.
ensure_cron_project() used to unconditionally mint and persist a 'Cron Jobs'
project the first time any code path saw a cron session, even on installs
that never created a project of their own. These tests pin the fix: creation
is now gated on whether the active profile already has at least one real
(non-system) project; lookup, renamed-root alias resolution, and legacy
untagged back-tagging are all unaffected. ensure_cron_project() itself is
never mocked here — only PROJECTS_FILE and profile-resolution helpers are.
"""
import json
import sqlite3
import threading
import time
import pytest
def _make_cron_state_db(path, *, cron_count=1):
"""state.db with a batch of cron-sourced sessions (mirrors #4842's helper)."""
conn = sqlite3.connect(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
);
"""
)
now = time.time()
for i in range(cron_count):
sid = f"cron_job{i:04d}_{int(now) + i}"
conn.execute(
"INSERT INTO sessions (id, source, session_source, title, model,"
" started_at, message_count, parent_session_id, ended_at, end_reason)"
" VALUES (?, 'cron', 'cron', 'Cron run', 'test-model', ?, 1, NULL, NULL, NULL)",
(sid, now + i),
)
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp)"
" VALUES (?, ?, 'user', 'cron task', ?)",
(f"cron_msg_{i:04d}", sid, now + i),
)
conn.commit()
conn.close()
def _write_projects(path, projects):
path.write_text(json.dumps(projects), encoding="utf-8")
@pytest.fixture(autouse=True)
def _isolate_projects(tmp_path, monkeypatch):
"""Point PROJECTS_FILE at a fresh tmp_path file; reset profile-alias caches."""
import api.config as cfg
import api.models as models
import api.profiles as profiles
projects_file = tmp_path / "projects.json"
monkeypatch.setattr(cfg, "PROJECTS_FILE", projects_file)
monkeypatch.setattr(models, "PROJECTS_FILE", projects_file)
monkeypatch.setattr(models, "_projects_migrated", True)
monkeypatch.setattr(models, "_CRON_PROJECT_LOCK", threading.Lock())
monkeypatch.setattr(models, "_WEBHOOK_PROJECT_LOCK", threading.Lock())
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [])
monkeypatch.setattr(profiles, "_active_profile", "default")
profiles._invalidate_root_profile_cache()
yield projects_file
profiles._invalidate_root_profile_cache()
def test_sidebar_scan_zero_user_projects_skips_cron_project_creation(tmp_path):
"""Reproduction anchor: listing the sidebar with a cron row must not
create or persist a Cron Jobs project when no user project exists."""
import api.models as models
projects_file = tmp_path / "projects.json"
db = tmp_path / "state.db"
_make_cron_state_db(db, cron_count=1)
rows = models._load_cli_sessions_uncached(
tmp_path, db, _cli_profile=None, include_claude_code=False,
)
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
assert len(cron_rows) == 1
assert cron_rows[0]["project_id"] is None
assert not projects_file.exists(), (
"gate must not persist a Cron Jobs project when the active profile "
"has zero user-created projects"
)
def test_recovery_endpoint_zero_user_projects_skips_cron_project_creation(monkeypatch, tmp_path):
import api.routes as routes
sid = "cron_recover_0001"
cron_meta = {
"session_id": sid,
"title": "Cron run",
"model": "test-model",
"created_at": 10.0,
"updated_at": 20.0,
"source_tag": "cron",
"raw_source": "cron",
"session_source": "cron",
"source_label": "Cron",
"is_cli_session": True,
"read_only": False,
}
messages = [{"role": "user", "content": "run"}, {"role": "assistant", "content": "done"}]
class FakeSession:
def __init__(self):
self.project_id = None
def save(self, touch_updated_at=False):
pass
def compact(self):
return {"session_id": sid, "title": "Cron run"}
fake = FakeSession()
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, _sid: None))
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid, profile=None: messages)
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [cron_meta])
monkeypatch.setattr(routes, "import_cli_session", lambda *a, **k: fake)
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *a, **k: None)
monkeypatch.setattr(routes, "_queue_generated_title_for_imported_session", lambda *a, **k: None)
response = routes._handle_session_import_cli(object(), {"session_id": sid})
assert response["imported"] is True
assert fake.project_id is None
assert not (tmp_path / "projects.json").exists()
def test_sidebar_scan_with_user_project_still_autoassigns_cron(tmp_path):
import api.models as models
projects_file = tmp_path / "projects.json"
_write_projects(projects_file, [
{"project_id": "user-proj-1", "name": "My Website", "profile": "default", "created_at": 1.0},
])
db = tmp_path / "state.db"
_make_cron_state_db(db, cron_count=1)
rows = models._load_cli_sessions_uncached(
tmp_path, db, _cli_profile=None, include_claude_code=False,
)
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
assert len(cron_rows) == 1
assert cron_rows[0]["project_id"] is not None
saved = json.loads(projects_file.read_text())
cron_saved = [p for p in saved if p["name"] == "Cron Jobs"]
assert len(cron_saved) == 1
def test_recovery_endpoint_with_user_project_still_autoassigns_cron(monkeypatch, tmp_path):
import api.routes as routes
projects_file = tmp_path / "projects.json"
_write_projects(projects_file, [
{"project_id": "user-proj-1", "name": "My Website", "profile": "default", "created_at": 1.0},
])
sid = "cron_recover_0002"
cron_meta = {
"session_id": sid,
"title": "Cron run",
"model": "test-model",
"created_at": 10.0,
"updated_at": 20.0,
"source_tag": "cron",
"raw_source": "cron",
"session_source": "cron",
"source_label": "Cron",
"is_cli_session": True,
"read_only": False,
}
messages = [{"role": "user", "content": "run"}, {"role": "assistant", "content": "done"}]
class FakeSession:
def __init__(self):
self.project_id = None
def save(self, touch_updated_at=False):
pass
def compact(self):
return {"session_id": sid, "title": "Cron run"}
fake = FakeSession()
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, _sid: None))
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid, profile=None: messages)
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [cron_meta])
monkeypatch.setattr(routes, "import_cli_session", lambda *a, **k: fake)
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *a, **k: None)
monkeypatch.setattr(routes, "_queue_generated_title_for_imported_session", lambda *a, **k: None)
response = routes._handle_session_import_cli(object(), {"session_id": sid})
assert response["imported"] is True
assert fake.project_id is not None
saved = json.loads(projects_file.read_text())
assert any(p["name"] == "Cron Jobs" for p in saved)
def test_preexisting_tagged_cron_project_resolves_when_zero_user_projects(tmp_path):
import api.models as models
projects_file = tmp_path / "projects.json"
_write_projects(projects_file, [
{"project_id": "existing-cron", "name": "Cron Jobs", "profile": "default", "color": "#6366f1", "created_at": 1.0},
])
db = tmp_path / "state.db"
_make_cron_state_db(db, cron_count=1)
rows = models._load_cli_sessions_uncached(
tmp_path, db, _cli_profile=None, include_claude_code=False,
)
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
assert cron_rows[0]["project_id"] == "existing-cron"
saved = json.loads(projects_file.read_text())
assert len(saved) == 1
assert saved[0]["project_id"] == "existing-cron"
def test_legacy_untagged_cron_project_back_tagged_when_zero_user_projects(tmp_path, monkeypatch):
import api.models as models
import api.profiles as profiles
# A non-default active profile is required to exercise the back-tag loop:
# with 'default' active, an untagged row's implicit profile and the active
# profile both alias to the root profile (_is_root_profile('default') is
# unconditionally True), so the FIRST (alias-match) lookup loop would
# already resolve it without ever reaching the back-tag loop below it.
# Mirrors the active-profile choice in the proven
# test_issue1614_project_profile_filtering.py::test_ensure_cron_project_back_tags_legacy_untagged.
monkeypatch.setattr(profiles, "_active_profile", "haku")
profiles._invalidate_root_profile_cache()
projects_file = tmp_path / "projects.json"
_write_projects(projects_file, [
{"project_id": "legacy-cron", "name": "Cron Jobs", "color": "#6366f1", "created_at": 1.0},
])
db = tmp_path / "state.db"
_make_cron_state_db(db, cron_count=1)
rows = models._load_cli_sessions_uncached(
tmp_path, db, _cli_profile=None, include_claude_code=False,
)
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
assert cron_rows[0]["project_id"] == "legacy-cron"
saved = json.loads(projects_file.read_text())
assert saved[0]["profile"] == "haku", (
"legacy untagged project must still be back-tagged even when the "
"create branch is gated off"
)
def test_renamed_root_alias_cron_project_resolves_when_zero_user_projects(tmp_path, monkeypatch):
import api.models as models
import api.profiles as profiles
projects_file = tmp_path / "projects.json"
_write_projects(projects_file, [
{"project_id": "root-alias-cron", "name": "Cron Jobs", "profile": "default", "color": "#6366f1", "created_at": 1.0},
])
monkeypatch.setattr(profiles, "list_profiles_api", lambda: [
{"name": "kinni", "is_default": True, "path": str(tmp_path)},
])
monkeypatch.setattr(profiles, "_active_profile", "kinni")
profiles._invalidate_root_profile_cache()
db = tmp_path / "state.db"
_make_cron_state_db(db, cron_count=1)
rows = models._load_cli_sessions_uncached(
tmp_path, db, _cli_profile=None, include_claude_code=False,
)
cron_rows = [r for r in rows if r["source_tag"] == "cron"]
assert cron_rows[0]["project_id"] == "root-alias-cron"
def test_webhook_project_still_auto_created_when_zero_user_projects(tmp_path):
"""Adjacent-condition row: the ungated webhook path must still
auto-create its dedicated project even in the same zero-user-project
scan where the cron path is now suppressed."""
import api.models as models
projects_file = tmp_path / "projects.json"
db_path = tmp_path / "state.db"
with sqlite3.connect(db_path) as conn:
conn.execute(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER,
started_at REAL, source TEXT, user_id TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL,
role TEXT NOT NULL, content TEXT, timestamp REAL NOT NULL
)
"""
)
conn.execute(
"INSERT INTO sessions (id, title, model, message_count, started_at, source, user_id)"
" VALUES ('webhook_route_1', NULL, 'test-model', 1, 20, 'webhook', 'webhook:read-later')"
)
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp)"
" VALUES ('webhook_route_1', 'user', 'payload', 21)"
)
rows = models._load_cli_sessions_uncached(
tmp_path, db_path, "default", include_claude_code=False,
)
webhook_rows = [r for r in rows if r["source_tag"] == "webhook"]
assert len(webhook_rows) == 1
assert webhook_rows[0]["project_id"] is not None
saved = json.loads(projects_file.read_text())
assert any(p["name"] == "Webhooks" for p in saved)
def test_profile_has_user_projects_ignores_system_named_projects(tmp_path):
import api.models as models
projects_file = tmp_path / "projects.json"
_write_projects(projects_file, [])
assert models._profile_has_user_projects() is False
_write_projects(projects_file, [
{"project_id": "c1", "name": "Cron Jobs", "profile": "default", "created_at": 1.0},
{"project_id": "w1", "name": "Webhooks", "profile": "default", "created_at": 1.0},
])
assert models._profile_has_user_projects() is False, (
"system-named projects don't count as opting in"
)
_write_projects(projects_file, [
{"project_id": "a1", "name": "Cron Jobs Archive", "profile": "default", "created_at": 1.0},
])
assert models._profile_has_user_projects() is True, (
"only exact reserved names are excluded; similar names are user projects"
)
_write_projects(projects_file, [
{"project_id": "c1", "name": "Cron Jobs", "profile": "default", "created_at": 1.0},
{"project_id": "u1", "name": "My Real Project", "profile": "default", "created_at": 2.0},
])
assert models._profile_has_user_projects() is True
def test_ensure_cron_project_default_create_true_ignores_gate(tmp_path):
"""A direct call with no `create` argument must keep today's
unconditional-create behavior, even with zero user projects — only the
two gated callers opt in."""
import api.models as models
projects_file = tmp_path / "projects.json"
pid = models.ensure_cron_project()
saved = json.loads(projects_file.read_text())
assert any(p["project_id"] == pid and p["name"] == "Cron Jobs" for p in saved)