"""Tests for the workflow-event timeline model and read API (#637). Covers the acceptance criteria: versioned schema, adaptation of control-plane events and Gitea handoff comments, filter by issue/PR/session, redaction of secret-like payloads, and stable pagination. """ import os import sqlite3 import sys import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from starlette.testclient import TestClient import control_plane_db from canonical_thread_handoff import format_cth_body from webui import timeline from webui.app import create_app def _seed_db(path: str) -> None: """Create a control-plane DB and seed scoped work_items + events.""" # Constructing ControlPlaneDB runs the schema migration once. control_plane_db.ControlPlaneDB(db_path=path) conn = sqlite3.connect(path) try: conn.execute( "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) " "VALUES (?,?,?,?,?,?,?)", ("prgs", "Scaled-Tech-Consulting", "Gitea-Tools", "issue", 637, "open", "2026-07-23T00:00:00Z"), ) issue_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] conn.execute( "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) " "VALUES (?,?,?,?,?,?,?)", ("prgs", "Scaled-Tech-Consulting", "Gitea-Tools", "pr", 813, "open", "2026-07-23T00:00:00Z"), ) pr_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] # A work item for a different repo — must never appear in prgs/Gitea-Tools scope. conn.execute( "INSERT INTO work_items(remote, org, repo, kind, number, state, updated_at) " "VALUES (?,?,?,?,?,?,?)", ("dadeschools", "Other", "Elsewhere", "issue", 1, "open", "2026-07-23T00:00:00Z"), ) other_wid = conn.execute("SELECT last_insert_rowid()").fetchone()[0] rows = [ (issue_wid, "allocation", "assigned author work", "2026-07-23T01:00:00Z"), (issue_wid, "lease.renew", "token=ghs_ABCDEF1234567890abcdef lease renewed", "2026-07-23T02:00:00Z"), (pr_wid, "pr.opened", "PR opened for review", "2026-07-23T03:00:00Z"), (other_wid, "allocation", "off-scope event", "2026-07-23T04:00:00Z"), ] conn.executemany( "INSERT INTO events(work_item_id, event_type, message, created_at) VALUES (?,?,?,?)", rows, ) conn.commit() finally: conn.close() class TestSchema(unittest.TestCase): def test_schema_is_versioned(self): self.assertIsInstance(timeline.TIMELINE_SCHEMA_VERSION, int) self.assertGreaterEqual(timeline.TIMELINE_SCHEMA_VERSION, 1) def test_event_to_dict_shape(self): ev = timeline.WorkflowEvent( source=timeline.SOURCE_CONTROL_PLANE, event_type="allocation", event_key="cp:1", timestamp="2026-07-23T01:00:00Z", issue_number=637, ) d = ev.to_dict() for key in ( "source", "event_type", "event_key", "timestamp", "actor", "role", "issue_number", "pr_number", "session_id", "tool_name", "decision", "message", "correlation_id", "evidence_refs", "sensitive", ): self.assertIn(key, d) self.assertEqual(d["evidence_refs"], []) class TestCpAdapter(unittest.TestCase): def test_issue_and_pr_mapping(self): rows = [ {"event_id": 1, "event_type": "allocation", "message": "x", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 637}, {"event_id": 2, "event_type": "pr.opened", "message": "y", "created_at": "2026-07-23T02:00:00Z", "kind": "pr", "number": 813}, ] events = timeline.adapt_cp_events(rows) self.assertEqual(len(events), 2) self.assertEqual(events[0].issue_number, 637) self.assertIsNone(events[0].pr_number) self.assertEqual(events[0].correlation_id, "issue#637") self.assertIsNone(events[1].issue_number) self.assertEqual(events[1].pr_number, 813) def test_malformed_rows_skipped(self): rows = [ {"event_id": None, "event_type": "x", "kind": "issue", "number": 1}, {"event_id": 5, "event_type": "", "kind": "issue", "number": 1}, {"event_id": 6, "event_type": "ok", "message": "m", "created_at": None, "kind": "issue", "number": 1}, ] events = timeline.adapt_cp_events(rows) self.assertEqual(len(events), 1) self.assertIsNone(events[0].timestamp) def test_sensitive_event_flagged(self): rows = [{"event_id": 1, "event_type": "lease.renew", "message": "m", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 1}] events = timeline.adapt_cp_events(rows) self.assertTrue(events[0].sensitive) class TestCthAdapter(unittest.TestCase): def test_cth_comment_becomes_event(self): body = format_cth_body( cth_type="Author Handoff", status="ready", next_owner="reviewer", decision="implement timeline", proof="commit abc1234 closes #637", next_action="review PR", ready_to_paste_prompt="Review PR #900 as reviewer", ) comments = [{"id": 42, "body": body, "created_at": "2026-07-23T05:00:00Z", "user": {"login": "jcwalker3"}}] events = timeline.adapt_cth_comments(comments, kind="issue", number=637) self.assertEqual(len(events), 1) ev = events[0] self.assertEqual(ev.source, timeline.SOURCE_GITEA_HANDOFF) self.assertEqual(ev.event_type, "handoff:Author Handoff") self.assertEqual(ev.actor, "jcwalker3") self.assertEqual(ev.issue_number, 637) self.assertEqual(ev.event_key, "cth:issue:637:42") self.assertIn("#637", ev.evidence_refs) self.assertIn("abc1234", ev.evidence_refs) def test_non_cth_comment_ignored(self): comments = [{"id": 1, "body": "just a normal comment", "created_at": "2026-07-23T05:00:00Z", "user": {"login": "x"}}] self.assertEqual(timeline.adapt_cth_comments(comments, kind="issue", number=1), []) class TestRedaction(unittest.TestCase): def test_cp_message_redacted(self): rows = [{"event_id": 1, "event_type": "lease", "message": "token=ghs_ABCDEF1234567890abcdef here", "created_at": "2026-07-23T01:00:00Z", "kind": "issue", "number": 1}] events = timeline.adapt_cp_events(rows) self.assertNotIn("ghs_ABCDEF1234567890abcdef", events[0].message or "") def test_handoff_decision_redacted(self): body = format_cth_body( cth_type="Blocker", status="blocked", next_owner="author", decision="password=SuperSecret123! must rotate", proof="none", next_action="rotate", ready_to_paste_prompt="Rotate the credential and retry", ) comments = [{"id": 7, "body": body, "created_at": "2026-07-23T05:00:00Z", "user": {"login": "x"}}] events = timeline.adapt_cth_comments(comments, kind="issue", number=1) self.assertNotIn("SuperSecret123!", events[0].decision or "") class TestFilterSortPaginate(unittest.TestCase): def _events(self): return [ timeline.WorkflowEvent(source="control_plane", event_type="a", event_key="cp:3", timestamp="2026-07-23T03:00:00Z", pr_number=813), timeline.WorkflowEvent(source="control_plane", event_type="b", event_key="cp:1", timestamp="2026-07-23T01:00:00Z", issue_number=637), timeline.WorkflowEvent(source="control_plane", event_type="c", event_key="cp:2", timestamp="2026-07-23T02:00:00Z", issue_number=637, session_id="sess-1"), ] def test_filter_by_issue(self): out = timeline.filter_events(self._events(), issue=637) self.assertEqual({e.event_key for e in out}, {"cp:1", "cp:2"}) def test_filter_by_pr(self): out = timeline.filter_events(self._events(), pr=813) self.assertEqual([e.event_key for e in out], ["cp:3"]) def test_filter_by_session(self): out = timeline.filter_events(self._events(), session="sess-1") self.assertEqual([e.event_key for e in out], ["cp:2"]) def test_stable_sort_ascending(self): out = timeline.sort_events(self._events()) self.assertEqual([e.event_key for e in out], ["cp:1", "cp:2", "cp:3"]) def test_missing_timestamp_sorts_last(self): evs = self._events() + [ timeline.WorkflowEvent(source="control_plane", event_type="z", event_key="cp:9", timestamp=None) ] out = timeline.sort_events(evs) self.assertEqual(out[-1].event_key, "cp:9") def test_pagination_windows_and_next_offset(self): evs = timeline.sort_events(self._events()) page1 = timeline.paginate(evs, limit=2, offset=0) self.assertEqual(len(page1.events), 2) self.assertEqual(page1.total, 3) self.assertEqual(page1.next_offset, 2) page2 = timeline.paginate(evs, limit=2, offset=2) self.assertEqual(len(page2.events), 1) self.assertIsNone(page2.next_offset) def test_pagination_bounds_coerced(self): evs = self._events() page = timeline.paginate(evs, limit=-5, offset=-3) self.assertGreaterEqual(page.limit, 1) self.assertEqual(page.offset, 0) class TestCpReader(unittest.TestCase): def test_reads_scoped_events_only(self): import tempfile with tempfile.TemporaryDirectory() as tmp: db = os.path.join(tmp, "cp.sqlite3") _seed_db(db) events, status = timeline.read_cp_events( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", db_path=db ) self.assertTrue(status.ok) # 3 scoped events; the dadeschools/Other event is excluded. self.assertEqual(len(events), 3) self.assertTrue(all(e.source == "control_plane" for e in events)) # Redaction applied to the token-bearing message. joined = " ".join(e.message or "" for e in events) self.assertNotIn("ghs_ABCDEF1234567890abcdef", joined) def test_missing_db_degrades(self): events, status = timeline.read_cp_events( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", db_path="/nonexistent/path/to/cp.sqlite3", ) self.assertEqual(events, []) self.assertFalse(status.ok) self.assertIsNotNone(status.reason) class TestLoadTimeline(unittest.TestCase): def test_handoff_not_run_without_thread_filter(self): import tempfile with tempfile.TemporaryDirectory() as tmp: db = os.path.join(tmp, "cp.sqlite3") _seed_db(db) snap = timeline.load_timeline( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", db_path=db ) d = snap.to_dict() handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] self.assertFalse(handoff["ok"]) self.assertIn("thread-scoped", handoff["reason"]) self.assertEqual(d["schema_version"], timeline.TIMELINE_SCHEMA_VERSION) def test_handoff_included_via_injected_source(self): import tempfile body = format_cth_body( cth_type="Author Handoff", status="ready", next_owner="reviewer", decision="d", proof="#637", next_action="review", ready_to_paste_prompt="Review PR #1 now", ) def source(kind, number): return [{"id": 1, "body": body, "created_at": "2026-07-23T09:00:00Z", "user": {"login": "jcwalker3"}}] with tempfile.TemporaryDirectory() as tmp: db = os.path.join(tmp, "cp.sqlite3") _seed_db(db) snap = timeline.load_timeline( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", issue=637, db_path=db, comment_source=source, ) d = snap.to_dict() handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] self.assertTrue(handoff["ok"]) self.assertEqual(handoff["count"], 1) # Both a CP event and the handoff event for issue 637 appear, sorted. kinds = {e["source"] for e in d["events"]} self.assertEqual(kinds, {"control_plane", "gitea_handoff"}) def test_failing_comment_source_degrades_only_handoff(self): import tempfile def boom(kind, number): raise RuntimeError("network down") with tempfile.TemporaryDirectory() as tmp: db = os.path.join(tmp, "cp.sqlite3") _seed_db(db) snap = timeline.load_timeline( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", issue=637, db_path=db, comment_source=boom, ) d = snap.to_dict() cp = [s for s in d["sources"] if s["name"] == "control_plane"][0] handoff = [s for s in d["sources"] if s["name"] == "gitea_handoff"][0] self.assertTrue(cp["ok"]) self.assertFalse(handoff["ok"]) self.assertIn("network down", handoff["reason"]) class TestTimelineApi(unittest.TestCase): def setUp(self): self._prev_db = os.environ.get(control_plane_db.DB_PATH_ENV) self._prev_offline = os.environ.get("WEBUI_TEST_OFFLINE") import tempfile self._tmpdir = tempfile.TemporaryDirectory() self._db = os.path.join(self._tmpdir.name, "cp.sqlite3") _seed_db(self._db) os.environ[control_plane_db.DB_PATH_ENV] = self._db os.environ["WEBUI_TEST_OFFLINE"] = "1" self.client = TestClient(create_app()) def tearDown(self): if self._prev_db is None: os.environ.pop(control_plane_db.DB_PATH_ENV, None) else: os.environ[control_plane_db.DB_PATH_ENV] = self._prev_db if self._prev_offline is None: os.environ.pop("WEBUI_TEST_OFFLINE", None) else: os.environ["WEBUI_TEST_OFFLINE"] = self._prev_offline self._tmpdir.cleanup() def test_api_returns_timeline(self): resp = self.client.get("/api/v1/timeline") self.assertEqual(resp.status_code, 200) body = resp.json() self.assertEqual(body["schema_version"], timeline.TIMELINE_SCHEMA_VERSION) self.assertIn("events", body) self.assertIn("pagination", body) self.assertGreaterEqual(body["pagination"]["total"], 1) def test_api_filter_by_issue(self): resp = self.client.get("/api/v1/timeline?issue=637") self.assertEqual(resp.status_code, 200) events = resp.json()["events"] self.assertTrue(events) self.assertTrue(all(e["issue_number"] == 637 for e in events)) def test_api_pagination(self): resp = self.client.get("/api/v1/timeline?limit=1&offset=0") self.assertEqual(resp.status_code, 200) pg = resp.json()["pagination"] self.assertEqual(pg["limit"], 1) self.assertEqual(len(resp.json()["events"]), 1) if pg["total"] > 1: self.assertTrue(pg["has_more"]) def test_api_is_read_only(self): resp = self.client.post("/api/v1/timeline") self.assertIn(resp.status_code, (404, 405)) def test_api_no_secret_leak(self): resp = self.client.get("/api/v1/timeline?issue=637") self.assertNotIn("ghs_ABCDEF1234567890abcdef", resp.text) if __name__ == "__main__": unittest.main()