| 1 | """Tests for the safe permission preflight contract.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import io |
| 6 | import json |
| 7 | import sys |
| 8 | from contextlib import redirect_stderr, redirect_stdout |
| 9 | from unittest import mock |
| 10 | |
| 11 | import last30days as cli |
| 12 | from lib import env, permission_preflight, pipeline |
| 13 | |
| 14 | DEFAULT_SAVE_DIR = "~" + "/Documents/Last30Days" |
| 15 | |
| 16 | |
| 17 | def _diag(**overrides): |
| 18 | base = { |
| 19 | "providers": { |
| 20 | "google": False, |
| 21 | "openai": False, |
| 22 | "xai": False, |
| 23 | "openrouter": False, |
| 24 | "perplexity": False, |
| 25 | }, |
| 26 | "has_scrapecreators": False, |
| 27 | "has_github": False, |
| 28 | "available_sources": ["reddit", "hackernews", "polymarket"], |
| 29 | "safe": True, |
| 30 | "config_source": "env_only", |
| 31 | "ignored_project_config": None, |
| 32 | "ignored_project_config_keys": [], |
| 33 | "ignored_endpoint_overrides": [], |
| 34 | "browser_cookies": {"mode": "plan_only", "browsers": [], "reads_values": False}, |
| 35 | "external_commands": {"yt-dlp": False, "digg-pp-cli": False, "gh": False}, |
| 36 | "credential_destinations": {"global_env": None}, |
| 37 | "local_writes": [], |
| 38 | "native_search": False, |
| 39 | } |
| 40 | base.update(overrides) |
| 41 | return base |
| 42 | |
| 43 | |
| 44 | def _diag_with_preflight(config=None, **overrides): |
| 45 | config = config or {} |
| 46 | diag = _diag(**overrides) |
| 47 | diag["permission_preflight"] = permission_preflight.build(config, diag) |
| 48 | return diag |
| 49 | |
| 50 | |
| 51 | def test_preflight_reports_safe_browser_default_without_cookie_values(): |
| 52 | diag = _diag() |
| 53 | preflight = permission_preflight.build({}, diag) |
| 54 | |
| 55 | assert preflight["status"] == "ready" |
| 56 | assert preflight["action_items"] == [] |
| 57 | browser = preflight["local_reads"]["browser_cookies"] |
| 58 | assert browser == { |
| 59 | "status": "off", |
| 60 | "mode": "plan_only", |
| 61 | "browsers": [], |
| 62 | "reads_values": False, |
| 63 | } |
| 64 | text = permission_preflight.render_text(preflight) |
| 65 | assert "Browser cookies: off" in text |
| 66 | assert "cookie values" not in text |
| 67 | |
| 68 | |
| 69 | def test_preflight_reports_configured_browser_without_reading_values(): |
| 70 | diag = _diag( |
| 71 | browser_cookies={"mode": "plan_only", "browsers": ["firefox"], "reads_values": False} |
| 72 | ) |
| 73 | preflight = permission_preflight.build({"FROM_BROWSER": "firefox"}, diag) |
| 74 | |
| 75 | browser = preflight["local_reads"]["browser_cookies"] |
| 76 | assert browser["status"] == "enabled_by_config" |
| 77 | assert browser["browsers"] == ["firefox"] |
| 78 | assert browser["reads_values"] is False |
| 79 | assert "preflight did not read cookie values" in permission_preflight.render_text(preflight) |
| 80 | |
| 81 | |
| 82 | def test_preflight_reports_conditional_report_on_save_without_definite_write(): |
| 83 | preflight = permission_preflight.build( |
| 84 | {}, |
| 85 | _diag(), |
| 86 | report_on_save_dir=DEFAULT_SAVE_DIR, |
| 87 | ) |
| 88 | |
| 89 | assert preflight["local_writes"] == [] |
| 90 | assert preflight["conditional_writes"] == [ |
| 91 | {"kind": "report_on_save", "path": DEFAULT_SAVE_DIR} |
| 92 | ] |
| 93 | rendered = permission_preflight.render_text(preflight) |
| 94 | assert "none planned" in rendered |
| 95 | assert f"Report (if saved): {DEFAULT_SAVE_DIR}" in rendered |
| 96 | |
| 97 | |
| 98 | def test_preflight_prefers_definite_save_dir_over_conditional_report_on_save(tmp_path): |
| 99 | save_dir = tmp_path / "reports" |
| 100 | preflight = permission_preflight.build( |
| 101 | {}, |
| 102 | _diag(), |
| 103 | planned_save_dir=str(save_dir), |
| 104 | report_on_save_dir=DEFAULT_SAVE_DIR, |
| 105 | ) |
| 106 | |
| 107 | assert preflight["local_writes"] == [{"kind": "report", "path": str(save_dir)}] |
| 108 | assert preflight["conditional_writes"] == [] |
| 109 | |
| 110 | |
| 111 | def test_preflight_dedupes_env_save_dir_against_conditional_report_on_save(): |
| 112 | preflight = permission_preflight.build( |
| 113 | {"LAST30DAYS_MEMORY_DIR": DEFAULT_SAVE_DIR}, |
| 114 | _diag(local_writes=[{"kind": "report", "path": DEFAULT_SAVE_DIR}]), |
| 115 | report_on_save_dir=DEFAULT_SAVE_DIR, |
| 116 | ) |
| 117 | |
| 118 | assert preflight["local_writes"] == [{"kind": "report", "path": DEFAULT_SAVE_DIR}] |
| 119 | assert preflight["conditional_writes"] == [] |
| 120 | |
| 121 | |
| 122 | def test_preflight_project_config_not_active_is_not_trusted_with_trust_env(): |
| 123 | preflight = permission_preflight.build( |
| 124 | {"LAST30DAYS_TRUST_PROJECT_CONFIG": "1"}, |
| 125 | _diag(config_source="env_only", ignored_project_config=None), |
| 126 | ) |
| 127 | |
| 128 | project = preflight["local_reads"]["project_config"] |
| 129 | assert project["status"] == "not_active" |
| 130 | assert project["trusted"] is False |
| 131 | |
| 132 | |
| 133 | def test_preflight_reports_ignored_project_config_without_secret_values(tmp_path, monkeypatch): |
| 134 | project_env = tmp_path / ".claude" / "last30days.env" |
| 135 | project_env.parent.mkdir() |
| 136 | project_env.write_text( |
| 137 | "OPENAI_BASE_URL=https://attacker.example\nOPENAI_API_KEY=sk-not-reported\n", |
| 138 | encoding="utf-8", |
| 139 | ) |
| 140 | monkeypatch.chdir(tmp_path) |
| 141 | monkeypatch.setattr(env, "CONFIG_FILE", None) |
| 142 | monkeypatch.setenv("OPENAI_API_KEY", "sk-global") |
| 143 | monkeypatch.delenv("LAST30DAYS_TRUST_PROJECT_CONFIG", raising=False) |
| 144 | |
| 145 | with mock.patch.object(env, "_load_keychain", return_value={}), \ |
| 146 | mock.patch.object(env, "_load_pass", return_value={}): |
| 147 | config = env.get_config( |
| 148 | policy=env.ConfigLoadPolicy( |
| 149 | browser_cookies="plan_only", |
| 150 | inspect_ignored_project_config=True, |
| 151 | ) |
| 152 | ) |
| 153 | diag = pipeline.diagnose(config, safe=True) |
| 154 | |
| 155 | preflight = diag["permission_preflight"] |
| 156 | assert preflight["local_reads"]["project_config"]["status"] == "ignored_untrusted" |
| 157 | assert preflight["network"]["ignored_endpoint_overrides"] == ["OPENAI_BASE_URL"] |
| 158 | rendered = permission_preflight.render_text(preflight) |
| 159 | assert "Project config: ignored untrusted file" in rendered |
| 160 | assert "OPENAI_API_KEY" in rendered |
| 161 | assert "sk-not-reported" not in str(preflight) |
| 162 | assert "sk-global" not in str(preflight) |
| 163 | assert "sk-not-reported" not in rendered |
| 164 | assert "sk-global" not in rendered |
| 165 | |
| 166 | |
| 167 | def test_diagnose_uses_preflight_endpoint_override_key_set(): |
| 168 | ignored_keys = sorted(permission_preflight.ENDPOINT_OVERRIDE_KEYS) + ["UNRELATED_KEY"] |
| 169 | diag = pipeline.diagnose( |
| 170 | { |
| 171 | "_IGNORED_PROJECT_CONFIG_KEYS": ignored_keys, |
| 172 | "_BROWSER_COOKIE_MODE": "off", |
| 173 | "_BROWSER_COOKIE_BROWSERS": [], |
| 174 | }, |
| 175 | safe=True, |
| 176 | ) |
| 177 | |
| 178 | assert sorted(diag["ignored_endpoint_overrides"]) == sorted( |
| 179 | permission_preflight.ENDPOINT_OVERRIDE_KEYS |
| 180 | ) |
| 181 | |
| 182 | |
| 183 | def test_cli_preflight_uses_plan_only_policy_and_does_not_run_research(monkeypatch): |
| 184 | seen: dict[str, object] = {} |
| 185 | |
| 186 | def fake_get_config(*, policy): |
| 187 | seen["policy"] = policy |
| 188 | return {"_BROWSER_COOKIE_MODE": policy.browser_cookies, "_BROWSER_COOKIE_BROWSERS": []} |
| 189 | |
| 190 | with mock.patch.object(cli.env, "get_config", side_effect=fake_get_config), \ |
| 191 | mock.patch.object(cli.pipeline, "diagnose", return_value=_diag_with_preflight()) as diagnose, \ |
| 192 | mock.patch.object(cli.pipeline, "run", side_effect=AssertionError("research should not run")), \ |
| 193 | mock.patch.object(sys, "argv", ["last30days.py", "--preflight"]): |
| 194 | stdout = io.StringIO() |
| 195 | stderr = io.StringIO() |
| 196 | with redirect_stdout(stdout), redirect_stderr(stderr): |
| 197 | assert cli.main() == 0 |
| 198 | |
| 199 | assert seen["policy"].browser_cookies == "plan_only" |
| 200 | assert seen["policy"].inspect_ignored_project_config is True |
| 201 | diagnose.assert_called_once() |
| 202 | assert "last30days preflight" in stdout.getvalue() |
| 203 | assert "Local writes:" in stdout.getvalue() |
| 204 | |
| 205 | |
| 206 | def test_cli_preflight_reuses_embedded_preflight_without_save_overrides(monkeypatch): |
| 207 | embedded = permission_preflight.build({}, _diag()) |
| 208 | diag = _diag(permission_preflight=embedded) |
| 209 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 210 | mock.patch.object(cli.pipeline, "diagnose", return_value=diag), \ |
| 211 | mock.patch.object(cli.permission_preflight, "build", side_effect=AssertionError("should reuse embedded preflight")), \ |
| 212 | mock.patch.object(sys, "argv", ["last30days.py", "--preflight", "--emit=json"]): |
| 213 | stdout = io.StringIO() |
| 214 | stderr = io.StringIO() |
| 215 | with redirect_stdout(stdout), redirect_stderr(stderr): |
| 216 | assert cli.main() == 0 |
| 217 | |
| 218 | payload = json.loads(stdout.getvalue()) |
| 219 | assert payload == embedded |
| 220 | |
| 221 | |
| 222 | def test_cli_preflight_reports_explicit_save_dir(monkeypatch, tmp_path): |
| 223 | save_dir = tmp_path / "reports" |
| 224 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 225 | mock.patch.object(cli.pipeline, "diagnose", return_value=_diag_with_preflight()), \ |
| 226 | mock.patch.object(sys, "argv", ["last30days.py", "--preflight", "--save-dir", str(save_dir)]): |
| 227 | stdout = io.StringIO() |
| 228 | stderr = io.StringIO() |
| 229 | with redirect_stdout(stdout), redirect_stderr(stderr): |
| 230 | assert cli.main() == 0 |
| 231 | |
| 232 | assert str(save_dir) in stdout.getvalue() |
| 233 | assert "report" in stdout.getvalue() |
| 234 | |
| 235 | |
| 236 | def test_cli_preflight_reports_conditional_save_dir(monkeypatch): |
| 237 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 238 | mock.patch.object(cli.pipeline, "diagnose", return_value=_diag_with_preflight()), \ |
| 239 | mock.patch.object( |
| 240 | sys, |
| 241 | "argv", |
| 242 | [ |
| 243 | "last30days.py", |
| 244 | "--preflight", |
| 245 | "--preflight-report-on-save-dir", |
| 246 | DEFAULT_SAVE_DIR, |
| 247 | ], |
| 248 | ): |
| 249 | stdout = io.StringIO() |
| 250 | stderr = io.StringIO() |
| 251 | with redirect_stdout(stdout), redirect_stderr(stderr): |
| 252 | assert cli.main() == 0 |
| 253 | |
| 254 | assert f"Report (if saved): {DEFAULT_SAVE_DIR}" in stdout.getvalue() |
| 255 | |
| 256 | |
| 257 | def test_cli_preflight_json_returns_structured_contract(monkeypatch): |
| 258 | with mock.patch.object(cli.env, "get_config", return_value={}), \ |
| 259 | mock.patch.object(cli.pipeline, "diagnose", return_value=_diag_with_preflight()), \ |
| 260 | mock.patch.object(sys, "argv", ["last30days.py", "--preflight", "--emit=json"]): |
| 261 | stdout = io.StringIO() |
| 262 | stderr = io.StringIO() |
| 263 | with redirect_stdout(stdout), redirect_stderr(stderr): |
| 264 | assert cli.main() == 0 |
| 265 | |
| 266 | payload = json.loads(stdout.getvalue()) |
| 267 | assert payload["local_reads"]["browser_cookies"]["status"] == "off" |
| 268 | assert payload["conditional_writes"] == [] |
| 269 | assert payload["safe"] is True |
| 270 |