|
| 1 | +"""Phase 6 — plugin_hook_runs: dialect-aware persistence. |
| 2 | +
|
| 3 | +Tests: |
| 4 | + PG mode: |
| 5 | + - Successful hook run → row in plugin_hook_runs with stdout/stderr/exit_code |
| 6 | + - Stdout > 1 MB → row.truncated = True, stdout truncated at 1 MB + marker |
| 7 | + - Stderr > 1 MB → row.truncated = True, stderr truncated at 1 MB + marker |
| 8 | + - Timed-out hook → row.metadata includes timed_out=True |
| 9 | +
|
| 10 | + SQLite mode (AC1 — invariant): |
| 11 | + - Successful hook run → .log file created in ADWs/logs/plugins/ |
| 12 | + - plugin_hook_runs table is NOT queried/written |
| 13 | +
|
| 14 | +Markers: |
| 15 | + @pytest.mark.postgres — requires DATABASE_URL pointing to PG (Docker) |
| 16 | + @pytest.mark.sqlite — SQLite only, no Docker required |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import json |
| 22 | +import os |
| 23 | +import stat |
| 24 | +import subprocess |
| 25 | +import sys |
| 26 | +import textwrap |
| 27 | +from datetime import datetime |
| 28 | +from pathlib import Path |
| 29 | +from unittest.mock import MagicMock, patch |
| 30 | + |
| 31 | +import pytest |
| 32 | +import sqlalchemy as sa |
| 33 | +from sqlalchemy import text as sa_text |
| 34 | + |
| 35 | +REPO_ROOT = Path(__file__).resolve().parents[2] |
| 36 | +BACKEND_DIR = REPO_ROOT / "dashboard" / "backend" |
| 37 | +sys.path.insert(0, str(BACKEND_DIR)) |
| 38 | + |
| 39 | +_ALEMBIC_DIR = REPO_ROOT / "dashboard" / "alembic" |
| 40 | + |
| 41 | +# --------------------------------------------------------------------------- |
| 42 | +# Helpers |
| 43 | +# --------------------------------------------------------------------------- |
| 44 | + |
| 45 | +def _alembic_upgrade(db_url: str) -> subprocess.CompletedProcess: |
| 46 | + env = {**os.environ, "DATABASE_URL": db_url} |
| 47 | + return subprocess.run( |
| 48 | + [sys.executable, "-m", "alembic", "upgrade", "head"], |
| 49 | + cwd=str(_ALEMBIC_DIR), |
| 50 | + capture_output=True, |
| 51 | + text=True, |
| 52 | + env=env, |
| 53 | + ) |
| 54 | + |
| 55 | + |
| 56 | +def _make_hook_plugin_dir(tmp_path: Path, slug: str, hook_name: str, script_body: str) -> Path: |
| 57 | + """Create a minimal plugin dir with a hooks/<hook_name>.sh script.""" |
| 58 | + plugin_dir = tmp_path / slug |
| 59 | + hooks_dir = plugin_dir / "hooks" |
| 60 | + hooks_dir.mkdir(parents=True) |
| 61 | + script = hooks_dir / f"{hook_name}.sh" |
| 62 | + script.write_text(f"#!/usr/bin/env bash\n{script_body}\n", encoding="utf-8") |
| 63 | + script.chmod(script.stat().st_mode | stat.S_IXUSR) |
| 64 | + return plugin_dir |
| 65 | + |
| 66 | + |
| 67 | +def _make_in_memory_sqlite_engine_with_log_table() -> sa.Engine: |
| 68 | + """Return a SQLite in-memory engine with plugin_hook_runs table.""" |
| 69 | + engine = sa.create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) |
| 70 | + with engine.begin() as conn: |
| 71 | + conn.execute(sa_text(""" |
| 72 | + CREATE TABLE IF NOT EXISTS plugin_hook_runs ( |
| 73 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 74 | + slug TEXT NOT NULL, |
| 75 | + hook_name TEXT NOT NULL, |
| 76 | + sha256 TEXT, |
| 77 | + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 78 | + ended_at DATETIME, |
| 79 | + duration_ms INTEGER, |
| 80 | + exit_code INTEGER, |
| 81 | + stdout TEXT, |
| 82 | + stderr TEXT, |
| 83 | + truncated INTEGER NOT NULL DEFAULT 0, |
| 84 | + metadata TEXT |
| 85 | + ) |
| 86 | + """)) |
| 87 | + return engine |
| 88 | + |
| 89 | + |
| 90 | +def _patch_dialect_pg(engine: sa.Engine): |
| 91 | + """Patch plugin_hook_runner so _persist_hook_run sees PostgreSQL dialect + given engine.""" |
| 92 | + return [ |
| 93 | + patch("plugin_hook_runner._get_dialect_name", return_value="postgresql"), |
| 94 | + patch("plugin_hook_runner._get_db_engine", return_value=engine), |
| 95 | + ] |
| 96 | + |
| 97 | + |
| 98 | +def _patch_dialect_sqlite(engine: sa.Engine): |
| 99 | + """Patch plugin_hook_runner so _persist_hook_run sees SQLite dialect.""" |
| 100 | + return [ |
| 101 | + patch("plugin_hook_runner._get_dialect_name", return_value="sqlite"), |
| 102 | + patch("plugin_hook_runner._get_db_engine", return_value=engine), |
| 103 | + ] |
| 104 | + |
| 105 | + |
| 106 | +# --------------------------------------------------------------------------- |
| 107 | +# Fixtures |
| 108 | +# --------------------------------------------------------------------------- |
| 109 | + |
| 110 | +@pytest.fixture() |
| 111 | +def pg_url(): |
| 112 | + """PG DATABASE_URL from env; skip if absent.""" |
| 113 | + url = os.environ.get("DATABASE_URL", "") |
| 114 | + if not url or "postgresql" not in url: |
| 115 | + pytest.skip("DATABASE_URL not pointing to PostgreSQL — skipping PG test") |
| 116 | + result = _alembic_upgrade(url) |
| 117 | + assert result.returncode == 0, f"PG alembic upgrade failed:\n{result.stderr}" |
| 118 | + return url |
| 119 | + |
| 120 | + |
| 121 | +@pytest.fixture() |
| 122 | +def pg_engine(pg_url): |
| 123 | + return sa.create_engine( |
| 124 | + pg_url.replace("postgresql://", "postgresql+psycopg2://", 1) |
| 125 | + if pg_url.startswith("postgresql://") and "+psycopg2" not in pg_url |
| 126 | + else pg_url |
| 127 | + ) |
| 128 | + |
| 129 | + |
| 130 | +@pytest.fixture() |
| 131 | +def mem_engine(): |
| 132 | + """In-memory SQLite engine with plugin_hook_runs table.""" |
| 133 | + return _make_in_memory_sqlite_engine_with_log_table() |
| 134 | + |
| 135 | + |
| 136 | +# --------------------------------------------------------------------------- |
| 137 | +# Imports (after sys.path is set) |
| 138 | +# --------------------------------------------------------------------------- |
| 139 | + |
| 140 | +from plugin_hook_runner import ( # noqa: E402 |
| 141 | + _persist_hook_run, |
| 142 | + run_lifecycle_hook, |
| 143 | +) |
| 144 | + |
| 145 | +# --------------------------------------------------------------------------- |
| 146 | +# PG mode tests |
| 147 | +# --------------------------------------------------------------------------- |
| 148 | + |
| 149 | +@pytest.mark.postgres |
| 150 | +def test_pg_successful_hook_inserts_row(tmp_path, pg_engine): |
| 151 | + """PG: successful hook run → row in plugin_hook_runs with correct fields.""" |
| 152 | + # Delete any leftover rows from prior test runs |
| 153 | + with pg_engine.begin() as conn: |
| 154 | + conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-phase6'")) |
| 155 | + |
| 156 | + plugin_dir = _make_hook_plugin_dir(tmp_path, "test-phase6", "post-install", "echo hello") |
| 157 | + |
| 158 | + patches = _patch_dialect_pg(pg_engine) |
| 159 | + with patches[0], patches[1]: |
| 160 | + result = run_lifecycle_hook(plugin_dir, "post-install", "test-phase6", timeout=10) |
| 161 | + |
| 162 | + assert result["ran"] is True |
| 163 | + assert result["exit_code"] == 0 |
| 164 | + |
| 165 | + with pg_engine.connect() as conn: |
| 166 | + row = conn.execute(sa_text( |
| 167 | + "SELECT slug, hook_name, exit_code, stdout, stderr, truncated " |
| 168 | + "FROM plugin_hook_runs WHERE slug = 'test-phase6' ORDER BY id DESC LIMIT 1" |
| 169 | + )).fetchone() |
| 170 | + |
| 171 | + assert row is not None, "Expected a row in plugin_hook_runs" |
| 172 | + assert row[0] == "test-phase6" |
| 173 | + assert row[1] == "post-install" |
| 174 | + assert row[2] == 0 |
| 175 | + assert "hello" in (row[3] or "") |
| 176 | + assert row[5] is False or row[5] == 0 # truncated = False |
| 177 | + |
| 178 | + |
| 179 | +@pytest.mark.postgres |
| 180 | +def test_pg_hook_row_has_duration_ms(tmp_path, pg_engine): |
| 181 | + """PG: row includes a positive duration_ms.""" |
| 182 | + with pg_engine.begin() as conn: |
| 183 | + conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-phase6-dur'")) |
| 184 | + |
| 185 | + plugin_dir = _make_hook_plugin_dir(tmp_path, "test-phase6-dur", "post-install", "echo dur") |
| 186 | + |
| 187 | + patches = _patch_dialect_pg(pg_engine) |
| 188 | + with patches[0], patches[1]: |
| 189 | + run_lifecycle_hook(plugin_dir, "post-install", "test-phase6-dur", timeout=10) |
| 190 | + |
| 191 | + with pg_engine.connect() as conn: |
| 192 | + row = conn.execute(sa_text( |
| 193 | + "SELECT duration_ms FROM plugin_hook_runs " |
| 194 | + "WHERE slug = 'test-phase6-dur' ORDER BY id DESC LIMIT 1" |
| 195 | + )).fetchone() |
| 196 | + |
| 197 | + assert row is not None |
| 198 | + assert isinstance(row[0], int) |
| 199 | + assert row[0] >= 0 |
| 200 | + |
| 201 | + |
| 202 | +@pytest.mark.postgres |
| 203 | +def test_pg_truncate_stdout(pg_engine): |
| 204 | + """PG: stdout > 1 MB → row.truncated = True, stdout cut at 1 MB + marker.""" |
| 205 | + with pg_engine.begin() as conn: |
| 206 | + conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-trunc-stdout'")) |
| 207 | + |
| 208 | + big_stdout = "x" * (1024 * 1024 + 500) |
| 209 | + started = datetime(2026, 1, 1, 12, 0, 0) |
| 210 | + ended = datetime(2026, 1, 1, 12, 0, 1) |
| 211 | + |
| 212 | + patches = _patch_dialect_pg(pg_engine) |
| 213 | + with patches[0], patches[1]: |
| 214 | + _persist_hook_run( |
| 215 | + slug="test-trunc-stdout", |
| 216 | + hook_name="post-install", |
| 217 | + timestamp="20260101T120000Z", |
| 218 | + script_sha256="abc123", |
| 219 | + started_at=started, |
| 220 | + ended_at=ended, |
| 221 | + exit_code=0, |
| 222 | + stdout=big_stdout, |
| 223 | + stderr="", |
| 224 | + timed_out=False, |
| 225 | + ) |
| 226 | + |
| 227 | + with pg_engine.connect() as conn: |
| 228 | + row = conn.execute(sa_text( |
| 229 | + "SELECT stdout, truncated FROM plugin_hook_runs " |
| 230 | + "WHERE slug = 'test-trunc-stdout' ORDER BY id DESC LIMIT 1" |
| 231 | + )).fetchone() |
| 232 | + |
| 233 | + assert row is not None |
| 234 | + stored_stdout = row[0] |
| 235 | + truncated = row[1] |
| 236 | + |
| 237 | + assert truncated is True or truncated == 1, "truncated must be True" |
| 238 | + assert len(stored_stdout) <= 1024 * 1024 + len("\n... [TRUNCATED]") + 10 |
| 239 | + assert stored_stdout.endswith("\n... [TRUNCATED]") |
| 240 | + |
| 241 | + |
| 242 | +@pytest.mark.postgres |
| 243 | +def test_pg_truncate_stderr(pg_engine): |
| 244 | + """PG: stderr > 1 MB → row.truncated = True, stderr cut at 1 MB + marker.""" |
| 245 | + with pg_engine.begin() as conn: |
| 246 | + conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-trunc-stderr'")) |
| 247 | + |
| 248 | + big_stderr = "e" * (1024 * 1024 + 100) |
| 249 | + started = datetime(2026, 1, 1, 13, 0, 0) |
| 250 | + ended = datetime(2026, 1, 1, 13, 0, 1) |
| 251 | + |
| 252 | + patches = _patch_dialect_pg(pg_engine) |
| 253 | + with patches[0], patches[1]: |
| 254 | + _persist_hook_run( |
| 255 | + slug="test-trunc-stderr", |
| 256 | + hook_name="post-install", |
| 257 | + timestamp="20260101T130000Z", |
| 258 | + script_sha256="def456", |
| 259 | + started_at=started, |
| 260 | + ended_at=ended, |
| 261 | + exit_code=0, |
| 262 | + stdout="", |
| 263 | + stderr=big_stderr, |
| 264 | + timed_out=False, |
| 265 | + ) |
| 266 | + |
| 267 | + with pg_engine.connect() as conn: |
| 268 | + row = conn.execute(sa_text( |
| 269 | + "SELECT stderr, truncated FROM plugin_hook_runs " |
| 270 | + "WHERE slug = 'test-trunc-stderr' ORDER BY id DESC LIMIT 1" |
| 271 | + )).fetchone() |
| 272 | + |
| 273 | + assert row is not None |
| 274 | + assert row[1] is True or row[1] == 1, "truncated must be True" |
| 275 | + assert row[0].endswith("\n... [TRUNCATED]") |
| 276 | + |
| 277 | + |
| 278 | +@pytest.mark.postgres |
| 279 | +def test_pg_timed_out_hook_metadata(tmp_path, pg_engine): |
| 280 | + """PG: timed-out hook sets metadata.timed_out=True.""" |
| 281 | + with pg_engine.begin() as conn: |
| 282 | + conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-timeout-meta'")) |
| 283 | + |
| 284 | + started = datetime(2026, 1, 1, 14, 0, 0) |
| 285 | + ended = datetime(2026, 1, 1, 14, 0, 5) |
| 286 | + |
| 287 | + patches = _patch_dialect_pg(pg_engine) |
| 288 | + with patches[0], patches[1]: |
| 289 | + _persist_hook_run( |
| 290 | + slug="test-timeout-meta", |
| 291 | + hook_name="post-install", |
| 292 | + timestamp="20260101T140000Z", |
| 293 | + script_sha256="fff000", |
| 294 | + started_at=started, |
| 295 | + ended_at=ended, |
| 296 | + exit_code=None, |
| 297 | + stdout="partial", |
| 298 | + stderr="", |
| 299 | + timed_out=True, |
| 300 | + metadata={"timeout_seconds": 60}, |
| 301 | + ) |
| 302 | + |
| 303 | + with pg_engine.connect() as conn: |
| 304 | + row = conn.execute(sa_text( |
| 305 | + "SELECT metadata FROM plugin_hook_runs " |
| 306 | + "WHERE slug = 'test-timeout-meta' ORDER BY id DESC LIMIT 1" |
| 307 | + )).fetchone() |
| 308 | + |
| 309 | + assert row is not None |
| 310 | + meta = json.loads(row[0]) |
| 311 | + assert meta.get("timed_out") is True |
| 312 | + assert meta.get("timeout_seconds") == 60 |
| 313 | + |
| 314 | + |
| 315 | +# --------------------------------------------------------------------------- |
| 316 | +# SQLite mode tests (AC1 — invariant: SQLite behaviour unchanged) |
| 317 | +# --------------------------------------------------------------------------- |
| 318 | + |
| 319 | +@pytest.mark.sqlite |
| 320 | +def test_sqlite_successful_hook_creates_log_file(tmp_path): |
| 321 | + """SQLite: successful hook run → .log file in ADWs/logs/plugins/.""" |
| 322 | + log_dir = tmp_path / "ADWs" / "logs" / "plugins" |
| 323 | + |
| 324 | + plugin_dir = _make_hook_plugin_dir(tmp_path, "test-sqlite-hook", "post-install", "echo sqlite-ok") |
| 325 | + |
| 326 | + # Patch PLUGIN_LOGS_DIR so logs land in tmp_path instead of repo root |
| 327 | + with patch("plugin_hook_runner.PLUGIN_LOGS_DIR", log_dir): |
| 328 | + # Use mem_engine for dialect patching (won't be written to in SQLite mode) |
| 329 | + mem_engine = _make_in_memory_sqlite_engine_with_log_table() |
| 330 | + patches = _patch_dialect_sqlite(mem_engine) |
| 331 | + with patches[0], patches[1]: |
| 332 | + result = run_lifecycle_hook(plugin_dir, "post-install", "test-sqlite-hook", timeout=10) |
| 333 | + |
| 334 | + assert result["ran"] is True |
| 335 | + assert result["exit_code"] == 0 |
| 336 | + assert result["log_path"] is not None |
| 337 | + |
| 338 | + # Log file must exist |
| 339 | + log_files = list(log_dir.glob("test-sqlite-hook-post-install-*.log")) |
| 340 | + assert len(log_files) == 1, f"Expected 1 log file, found: {log_files}" |
| 341 | + |
| 342 | + content = log_files[0].read_text(encoding="utf-8") |
| 343 | + assert "plugin: test-sqlite-hook" in content |
| 344 | + assert "hook: post-install" in content |
| 345 | + assert "sqlite-ok" in content |
| 346 | + |
| 347 | + |
| 348 | +@pytest.mark.sqlite |
| 349 | +def test_sqlite_hook_does_not_write_to_db_table(tmp_path): |
| 350 | + """SQLite: hook run does NOT write to plugin_hook_runs (AC1).""" |
| 351 | + log_dir = tmp_path / "ADWs" / "logs" / "plugins" |
| 352 | + plugin_dir = _make_hook_plugin_dir(tmp_path, "test-no-db", "post-install", "echo noop") |
| 353 | + |
| 354 | + mem_engine = _make_in_memory_sqlite_engine_with_log_table() |
| 355 | + |
| 356 | + with patch("plugin_hook_runner.PLUGIN_LOGS_DIR", log_dir): |
| 357 | + patches = _patch_dialect_sqlite(mem_engine) |
| 358 | + with patches[0], patches[1]: |
| 359 | + run_lifecycle_hook(plugin_dir, "post-install", "test-no-db", timeout=10) |
| 360 | + |
| 361 | + with mem_engine.connect() as conn: |
| 362 | + count = conn.execute(sa_text("SELECT COUNT(*) FROM plugin_hook_runs")).scalar() |
| 363 | + |
| 364 | + assert count == 0, f"SQLite mode must not write to plugin_hook_runs; got {count} rows" |
| 365 | + |
| 366 | + |
| 367 | +@pytest.mark.sqlite |
| 368 | +def test_sqlite_truncation_still_applies_to_log_file(tmp_path): |
| 369 | + """SQLite: stdout > 1 MB is truncated in the log file content.""" |
| 370 | + log_dir = tmp_path / "ADWs" / "logs" / "plugins" |
| 371 | + log_dir.mkdir(parents=True, exist_ok=True) |
| 372 | + |
| 373 | + big_stdout = "x" * (1024 * 1024 + 200) |
| 374 | + started = datetime(2026, 1, 1, 10, 0, 0) |
| 375 | + ended = datetime(2026, 1, 1, 10, 0, 1) |
| 376 | + |
| 377 | + mem_engine = _make_in_memory_sqlite_engine_with_log_table() |
| 378 | + |
| 379 | + with patch("plugin_hook_runner.PLUGIN_LOGS_DIR", log_dir): |
| 380 | + patches = _patch_dialect_sqlite(mem_engine) |
| 381 | + with patches[0], patches[1]: |
| 382 | + _persist_hook_run( |
| 383 | + slug="test-sqlite-trunc", |
| 384 | + hook_name="post-install", |
| 385 | + timestamp="20260101T100000Z", |
| 386 | + script_sha256="aaa111", |
| 387 | + started_at=started, |
| 388 | + ended_at=ended, |
| 389 | + exit_code=0, |
| 390 | + stdout=big_stdout, |
| 391 | + stderr="", |
| 392 | + timed_out=False, |
| 393 | + ) |
| 394 | + |
| 395 | + log_files = list(log_dir.glob("test-sqlite-trunc-*.log")) |
| 396 | + assert len(log_files) == 1 |
| 397 | + content = log_files[0].read_text(encoding="utf-8") |
| 398 | + assert "[TRUNCATED]" in content |
0 commit comments