draft-board 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/app/backend/.env.example +9 -0
  2. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
  3. package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
  4. package/app/backend/CURL_EXAMPLES.md +335 -0
  5. package/app/backend/ENV_SETUP.md +65 -0
  6. package/app/backend/alembic/env.py +71 -0
  7. package/app/backend/alembic/script.py.mako +28 -0
  8. package/app/backend/alembic/versions/001_initial_schema.py +104 -0
  9. package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
  10. package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
  11. package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
  12. package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
  13. package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
  14. package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
  15. package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
  16. package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
  17. package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
  18. package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
  19. package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
  20. package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
  21. package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
  22. package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
  23. package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
  24. package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
  25. package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
  26. package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
  27. package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
  28. package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
  29. package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
  30. package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
  31. package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
  32. package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
  33. package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
  34. package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
  35. package/app/backend/alembic/versions/add_job_variant.py +34 -0
  36. package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
  37. package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
  38. package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
  39. package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
  40. package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
  41. package/app/backend/alembic.ini +89 -0
  42. package/app/backend/app/__init__.py +3 -0
  43. package/app/backend/app/data_dir.py +85 -0
  44. package/app/backend/app/database.py +70 -0
  45. package/app/backend/app/database_sync.py +64 -0
  46. package/app/backend/app/dependencies/__init__.py +5 -0
  47. package/app/backend/app/dependencies/auth.py +80 -0
  48. package/app/backend/app/dependencies.py +43 -0
  49. package/app/backend/app/exceptions.py +178 -0
  50. package/app/backend/app/executors/__init__.py +1 -0
  51. package/app/backend/app/executors/adapters/__init__.py +1 -0
  52. package/app/backend/app/executors/adapters/aider.py +152 -0
  53. package/app/backend/app/executors/adapters/amazon_q.py +103 -0
  54. package/app/backend/app/executors/adapters/amp.py +123 -0
  55. package/app/backend/app/executors/adapters/claude.py +177 -0
  56. package/app/backend/app/executors/adapters/cline.py +127 -0
  57. package/app/backend/app/executors/adapters/codex.py +167 -0
  58. package/app/backend/app/executors/adapters/copilot.py +202 -0
  59. package/app/backend/app/executors/adapters/cursor.py +87 -0
  60. package/app/backend/app/executors/adapters/droid.py +123 -0
  61. package/app/backend/app/executors/adapters/gemini.py +132 -0
  62. package/app/backend/app/executors/adapters/goose.py +131 -0
  63. package/app/backend/app/executors/adapters/opencode.py +123 -0
  64. package/app/backend/app/executors/adapters/qwen.py +123 -0
  65. package/app/backend/app/executors/plugins/__init__.py +1 -0
  66. package/app/backend/app/executors/registry.py +202 -0
  67. package/app/backend/app/executors/spec.py +226 -0
  68. package/app/backend/app/main.py +486 -0
  69. package/app/backend/app/middleware/__init__.py +13 -0
  70. package/app/backend/app/middleware/idempotency.py +426 -0
  71. package/app/backend/app/middleware/rate_limit.py +312 -0
  72. package/app/backend/app/middleware/security_headers.py +43 -0
  73. package/app/backend/app/middleware/timeout.py +37 -0
  74. package/app/backend/app/models/__init__.py +56 -0
  75. package/app/backend/app/models/agent_conversation_history.py +56 -0
  76. package/app/backend/app/models/agent_session.py +127 -0
  77. package/app/backend/app/models/analysis_cache.py +49 -0
  78. package/app/backend/app/models/base.py +9 -0
  79. package/app/backend/app/models/board.py +79 -0
  80. package/app/backend/app/models/board_repo.py +68 -0
  81. package/app/backend/app/models/cost_budget.py +42 -0
  82. package/app/backend/app/models/enums.py +40 -0
  83. package/app/backend/app/models/evidence.py +132 -0
  84. package/app/backend/app/models/goal.py +102 -0
  85. package/app/backend/app/models/idempotency_entry.py +30 -0
  86. package/app/backend/app/models/job.py +163 -0
  87. package/app/backend/app/models/job_queue.py +39 -0
  88. package/app/backend/app/models/kv_store.py +28 -0
  89. package/app/backend/app/models/merge_checklist.py +87 -0
  90. package/app/backend/app/models/normalized_log.py +100 -0
  91. package/app/backend/app/models/planner_lock.py +43 -0
  92. package/app/backend/app/models/rate_limit_entry.py +25 -0
  93. package/app/backend/app/models/repo.py +66 -0
  94. package/app/backend/app/models/review_comment.py +91 -0
  95. package/app/backend/app/models/review_summary.py +69 -0
  96. package/app/backend/app/models/revision.py +130 -0
  97. package/app/backend/app/models/ticket.py +223 -0
  98. package/app/backend/app/models/ticket_event.py +83 -0
  99. package/app/backend/app/models/user.py +47 -0
  100. package/app/backend/app/models/workspace.py +71 -0
  101. package/app/backend/app/redis_client.py +119 -0
  102. package/app/backend/app/routers/__init__.py +29 -0
  103. package/app/backend/app/routers/agents.py +296 -0
  104. package/app/backend/app/routers/auth.py +94 -0
  105. package/app/backend/app/routers/board.py +885 -0
  106. package/app/backend/app/routers/dashboard.py +351 -0
  107. package/app/backend/app/routers/debug.py +528 -0
  108. package/app/backend/app/routers/evidence.py +96 -0
  109. package/app/backend/app/routers/executors.py +324 -0
  110. package/app/backend/app/routers/goals.py +574 -0
  111. package/app/backend/app/routers/jobs.py +448 -0
  112. package/app/backend/app/routers/maintenance.py +172 -0
  113. package/app/backend/app/routers/merge.py +360 -0
  114. package/app/backend/app/routers/planner.py +537 -0
  115. package/app/backend/app/routers/pull_requests.py +382 -0
  116. package/app/backend/app/routers/repos.py +263 -0
  117. package/app/backend/app/routers/revisions.py +939 -0
  118. package/app/backend/app/routers/settings.py +267 -0
  119. package/app/backend/app/routers/tickets.py +2003 -0
  120. package/app/backend/app/routers/webhooks.py +143 -0
  121. package/app/backend/app/routers/websocket.py +249 -0
  122. package/app/backend/app/schemas/__init__.py +109 -0
  123. package/app/backend/app/schemas/board.py +87 -0
  124. package/app/backend/app/schemas/common.py +33 -0
  125. package/app/backend/app/schemas/evidence.py +87 -0
  126. package/app/backend/app/schemas/goal.py +90 -0
  127. package/app/backend/app/schemas/job.py +97 -0
  128. package/app/backend/app/schemas/merge.py +139 -0
  129. package/app/backend/app/schemas/planner.py +500 -0
  130. package/app/backend/app/schemas/repo.py +187 -0
  131. package/app/backend/app/schemas/review.py +137 -0
  132. package/app/backend/app/schemas/revision.py +114 -0
  133. package/app/backend/app/schemas/ticket.py +238 -0
  134. package/app/backend/app/schemas/ticket_event.py +72 -0
  135. package/app/backend/app/schemas/workspace.py +19 -0
  136. package/app/backend/app/services/__init__.py +31 -0
  137. package/app/backend/app/services/agent_memory_service.py +223 -0
  138. package/app/backend/app/services/agent_registry.py +346 -0
  139. package/app/backend/app/services/agent_session_manager.py +318 -0
  140. package/app/backend/app/services/agent_session_service.py +219 -0
  141. package/app/backend/app/services/agent_tools.py +379 -0
  142. package/app/backend/app/services/auth_service.py +98 -0
  143. package/app/backend/app/services/autonomy_service.py +380 -0
  144. package/app/backend/app/services/board_repo_service.py +201 -0
  145. package/app/backend/app/services/board_service.py +326 -0
  146. package/app/backend/app/services/cleanup_service.py +1085 -0
  147. package/app/backend/app/services/config_service.py +908 -0
  148. package/app/backend/app/services/context_gatherer.py +557 -0
  149. package/app/backend/app/services/cost_tracking_service.py +293 -0
  150. package/app/backend/app/services/cursor_log_normalizer.py +536 -0
  151. package/app/backend/app/services/delivery_pipeline.py +440 -0
  152. package/app/backend/app/services/executor_service.py +634 -0
  153. package/app/backend/app/services/git_host/__init__.py +11 -0
  154. package/app/backend/app/services/git_host/factory.py +87 -0
  155. package/app/backend/app/services/git_host/github.py +270 -0
  156. package/app/backend/app/services/git_host/gitlab.py +194 -0
  157. package/app/backend/app/services/git_host/protocol.py +75 -0
  158. package/app/backend/app/services/git_merge_simple.py +346 -0
  159. package/app/backend/app/services/git_ops.py +384 -0
  160. package/app/backend/app/services/github_service.py +233 -0
  161. package/app/backend/app/services/goal_service.py +113 -0
  162. package/app/backend/app/services/job_service.py +423 -0
  163. package/app/backend/app/services/job_watchdog_service.py +424 -0
  164. package/app/backend/app/services/langchain_adapter.py +122 -0
  165. package/app/backend/app/services/llm_provider_clients.py +351 -0
  166. package/app/backend/app/services/llm_service.py +285 -0
  167. package/app/backend/app/services/log_normalizer.py +342 -0
  168. package/app/backend/app/services/log_stream_service.py +276 -0
  169. package/app/backend/app/services/merge_checklist_service.py +264 -0
  170. package/app/backend/app/services/merge_service.py +784 -0
  171. package/app/backend/app/services/orchestrator_log.py +84 -0
  172. package/app/backend/app/services/planner_service.py +1662 -0
  173. package/app/backend/app/services/planner_tick_sync.py +1040 -0
  174. package/app/backend/app/services/queued_message_service.py +156 -0
  175. package/app/backend/app/services/reliability_wrapper.py +389 -0
  176. package/app/backend/app/services/repo_discovery_service.py +318 -0
  177. package/app/backend/app/services/review_service.py +334 -0
  178. package/app/backend/app/services/revision_service.py +389 -0
  179. package/app/backend/app/services/safe_autopilot.py +510 -0
  180. package/app/backend/app/services/sqlite_worker.py +372 -0
  181. package/app/backend/app/services/task_dispatch.py +135 -0
  182. package/app/backend/app/services/ticket_generation_service.py +1781 -0
  183. package/app/backend/app/services/ticket_service.py +486 -0
  184. package/app/backend/app/services/udar_planner_service.py +1007 -0
  185. package/app/backend/app/services/webhook_service.py +126 -0
  186. package/app/backend/app/services/workspace_service.py +465 -0
  187. package/app/backend/app/services/worktree_file_service.py +92 -0
  188. package/app/backend/app/services/worktree_validator.py +213 -0
  189. package/app/backend/app/sqlite_kv.py +278 -0
  190. package/app/backend/app/state_machine.py +128 -0
  191. package/app/backend/app/templates/__init__.py +5 -0
  192. package/app/backend/app/templates/registry.py +243 -0
  193. package/app/backend/app/utils/__init__.py +5 -0
  194. package/app/backend/app/utils/artifact_reader.py +87 -0
  195. package/app/backend/app/utils/circuit_breaker.py +229 -0
  196. package/app/backend/app/utils/db_retry.py +136 -0
  197. package/app/backend/app/utils/ignored_fields.py +123 -0
  198. package/app/backend/app/utils/validators.py +54 -0
  199. package/app/backend/app/websocket/__init__.py +5 -0
  200. package/app/backend/app/websocket/manager.py +179 -0
  201. package/app/backend/app/websocket/state_tracker.py +113 -0
  202. package/app/backend/app/worker.py +3190 -0
  203. package/app/backend/calculator_tickets.json +40 -0
  204. package/app/backend/canary_tests.sh +591 -0
  205. package/app/backend/celerybeat-schedule +0 -0
  206. package/app/backend/celerybeat-schedule-shm +0 -0
  207. package/app/backend/celerybeat-schedule-wal +0 -0
  208. package/app/backend/logs/.gitkeep +3 -0
  209. package/app/backend/multiplication_division_implementation_tickets.json +55 -0
  210. package/app/backend/multiplication_division_tickets.json +42 -0
  211. package/app/backend/pyproject.toml +45 -0
  212. package/app/backend/requirements-dev.txt +8 -0
  213. package/app/backend/requirements.txt +20 -0
  214. package/app/backend/run.sh +30 -0
  215. package/app/backend/run_with_logs.sh +10 -0
  216. package/app/backend/scientific_calculator_tickets.json +40 -0
  217. package/app/backend/scripts/extract_openapi.py +21 -0
  218. package/app/backend/scripts/seed_demo.py +187 -0
  219. package/app/backend/setup_demo_review.py +302 -0
  220. package/app/backend/test_actual_parse.py +41 -0
  221. package/app/backend/test_agent_streaming.py +61 -0
  222. package/app/backend/test_parse.py +51 -0
  223. package/app/backend/test_streaming.py +51 -0
  224. package/app/backend/test_subprocess_streaming.py +50 -0
  225. package/app/backend/tests/__init__.py +1 -0
  226. package/app/backend/tests/conftest.py +46 -0
  227. package/app/backend/tests/test_auth.py +341 -0
  228. package/app/backend/tests/test_autonomy_service.py +391 -0
  229. package/app/backend/tests/test_cleanup_service_safety.py +417 -0
  230. package/app/backend/tests/test_middleware.py +279 -0
  231. package/app/backend/tests/test_planner_providers.py +290 -0
  232. package/app/backend/tests/test_planner_unblock.py +183 -0
  233. package/app/backend/tests/test_revision_invariants.py +618 -0
  234. package/app/backend/tests/test_sqlite_kv.py +290 -0
  235. package/app/backend/tests/test_sqlite_worker.py +353 -0
  236. package/app/backend/tests/test_task_dispatch.py +100 -0
  237. package/app/backend/tests/test_ticket_validation.py +304 -0
  238. package/app/backend/tests/test_udar_agent.py +693 -0
  239. package/app/backend/tests/test_webhook_service.py +184 -0
  240. package/app/backend/tickets_output.json +59 -0
  241. package/app/backend/user_management_tickets.json +50 -0
  242. package/app/backend/uvicorn.log +0 -0
  243. package/app/draft.yaml +313 -0
  244. package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
  245. package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
  246. package/app/frontend/dist/index.html +14 -0
  247. package/app/frontend/dist/vite.svg +1 -0
  248. package/app/frontend/package.json +101 -0
  249. package/bin/cli.js +527 -0
  250. package/package.json +37 -0
@@ -0,0 +1,417 @@
1
+ """Tests for cleanup service safety guards.
2
+
3
+ These tests verify the most dangerous scenarios are properly handled:
4
+ - git worktree remove fails but path remains registered
5
+ - Worktree path equals main repo path (symlink attacks)
6
+ """
7
+
8
+ import json
9
+ import tempfile
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from unittest.mock import MagicMock, patch
13
+ from uuid import uuid4
14
+
15
+ import pytest
16
+ from sqlalchemy import select
17
+ from sqlalchemy.ext.asyncio import AsyncSession
18
+
19
+ from app.models.enums import EventType
20
+ from app.models.goal import Goal
21
+ from app.models.ticket import Ticket
22
+ from app.models.ticket_event import TicketEvent
23
+ from app.models.workspace import Workspace
24
+ from app.services.cleanup_service import CleanupService, _sanitize_output
25
+
26
+
27
+ class TestSanitizeOutput:
28
+ """Test the _sanitize_output helper function."""
29
+
30
+ def test_removes_null_bytes(self):
31
+ """Null bytes should be stripped."""
32
+ input_text = "hello\x00world"
33
+ result = _sanitize_output(input_text)
34
+ assert "\x00" not in result
35
+ assert result == "helloworld"
36
+
37
+ def test_removes_control_characters(self):
38
+ """Control characters (except newline/tab) should be stripped."""
39
+ # \x01 is SOH, \x7f is DEL
40
+ input_text = "hello\x01world\x7f!"
41
+ result = _sanitize_output(input_text)
42
+ assert result == "helloworld!"
43
+
44
+ def test_preserves_newlines_and_tabs(self):
45
+ """Newlines and tabs should be preserved."""
46
+ input_text = "line1\nline2\ttab"
47
+ result = _sanitize_output(input_text)
48
+ assert result == "line1\nline2\ttab"
49
+
50
+ def test_removes_carriage_returns(self):
51
+ """Carriage returns should be stripped (Windows line endings)."""
52
+ input_text = "line1\r\nline2\rline3"
53
+ result = _sanitize_output(input_text)
54
+ # \r should be stripped, \n should remain
55
+ assert "\r" not in result
56
+ assert result == "line1\nline2line3"
57
+
58
+ def test_truncates_to_max_length(self):
59
+ """Output should be truncated to max_length."""
60
+ input_text = "a" * 1000
61
+ result = _sanitize_output(input_text, max_length=100)
62
+ assert len(result) == 100
63
+
64
+ def test_handles_none(self):
65
+ """None input should return None."""
66
+ assert _sanitize_output(None) is None
67
+
68
+
69
+ class TestCleanupServicePathValidation:
70
+ """Test path validation safety guards."""
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_repo_path_equals_worktree_path_is_blocked(self, db: AsyncSession):
74
+ """Test that cleanup is blocked when worktree path resolves to repo path.
75
+
76
+ This prevents symlink attacks where .draft/worktrees/foo -> /repo
77
+ """
78
+ # Create test entities
79
+ goal = Goal(id=str(uuid4()), title="Test Goal")
80
+ db.add(goal)
81
+ await db.flush()
82
+
83
+ ticket = Ticket(
84
+ id=str(uuid4()),
85
+ goal_id=goal.id,
86
+ title="Test ticket",
87
+ state="done",
88
+ )
89
+ db.add(ticket)
90
+ await db.flush()
91
+
92
+ with tempfile.TemporaryDirectory() as tmpdir:
93
+ repo_path = Path(tmpdir)
94
+ worktrees_dir = repo_path / ".draft/worktrees"
95
+ worktrees_dir.mkdir(parents=True)
96
+
97
+ # Create a symlink that points back to repo root
98
+ evil_symlink = worktrees_dir / "evil-link"
99
+ evil_symlink.symlink_to(repo_path)
100
+
101
+ workspace = Workspace(
102
+ id=str(uuid4()),
103
+ ticket_id=ticket.id,
104
+ worktree_path=str(evil_symlink),
105
+ branch_name="test-branch",
106
+ created_at=datetime.now(UTC),
107
+ )
108
+ db.add(workspace)
109
+ await db.commit()
110
+
111
+ service = CleanupService(db)
112
+
113
+ # Patch WorkspaceService.get_repo_path to return our temp repo
114
+ with patch(
115
+ "app.services.cleanup_service.WorkspaceService.get_repo_path",
116
+ return_value=repo_path,
117
+ ):
118
+ # Execute cleanup - should be blocked
119
+ result = await service.delete_worktree(
120
+ workspace=workspace,
121
+ ticket_id=ticket.id,
122
+ actor_id="test-actor",
123
+ force=False,
124
+ delete_branch=False,
125
+ )
126
+
127
+ # Assert: blocked
128
+ assert result is False
129
+
130
+ # Assert: WORKTREE_CLEANUP_FAILED event
131
+ events_result = await db.execute(
132
+ select(TicketEvent)
133
+ .where(TicketEvent.ticket_id == ticket.id)
134
+ .where(
135
+ TicketEvent.event_type == EventType.WORKTREE_CLEANUP_FAILED.value
136
+ )
137
+ )
138
+ events = events_result.scalars().all()
139
+ assert len(events) == 1
140
+
141
+ payload = json.loads(events[0].payload_json)
142
+ assert payload.get("cleanup_failed") is True
143
+
144
+ # Assert: cleaned_up_at remains NULL
145
+ await db.refresh(workspace)
146
+ assert workspace.cleaned_up_at is None
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_worktree_path_outside_draft_dir_is_blocked(self, db: AsyncSession):
150
+ """Test that cleanup is blocked for paths outside .draft/worktrees."""
151
+ goal = Goal(id=str(uuid4()), title="Test Goal")
152
+ db.add(goal)
153
+ await db.flush()
154
+
155
+ ticket = Ticket(
156
+ id=str(uuid4()),
157
+ goal_id=goal.id,
158
+ title="Test ticket",
159
+ state="done",
160
+ )
161
+ db.add(ticket)
162
+ await db.flush()
163
+
164
+ with tempfile.TemporaryDirectory() as tmpdir:
165
+ repo_path = Path(tmpdir)
166
+ # Create worktrees dir but put workspace path elsewhere
167
+ worktrees_dir = repo_path / ".draft/worktrees"
168
+ worktrees_dir.mkdir(parents=True)
169
+
170
+ # Path that's NOT under .draft/worktrees
171
+ evil_path = repo_path / "src" / "evil-dir"
172
+ evil_path.mkdir(parents=True)
173
+
174
+ workspace = Workspace(
175
+ id=str(uuid4()),
176
+ ticket_id=ticket.id,
177
+ worktree_path=str(evil_path),
178
+ branch_name="test-branch",
179
+ created_at=datetime.now(UTC),
180
+ )
181
+ db.add(workspace)
182
+ await db.commit()
183
+
184
+ service = CleanupService(db)
185
+
186
+ with patch(
187
+ "app.services.cleanup_service.WorkspaceService.get_repo_path",
188
+ return_value=repo_path,
189
+ ):
190
+ result = await service.delete_worktree(
191
+ workspace=workspace,
192
+ ticket_id=ticket.id,
193
+ actor_id="test-actor",
194
+ force=False,
195
+ delete_branch=False,
196
+ )
197
+
198
+ assert result is False
199
+
200
+ events_result = await db.execute(
201
+ select(TicketEvent)
202
+ .where(TicketEvent.ticket_id == ticket.id)
203
+ .where(
204
+ TicketEvent.event_type == EventType.WORKTREE_CLEANUP_FAILED.value
205
+ )
206
+ )
207
+ events = events_result.scalars().all()
208
+ assert len(events) == 1
209
+ assert "not under" in events[0].reason.lower()
210
+
211
+
212
+ class TestCleanupServiceStillRegistered:
213
+ """Test handling of worktrees that remain registered after removal attempt."""
214
+
215
+ @pytest.mark.asyncio
216
+ async def test_still_registered_after_remove_fails_returns_false(
217
+ self, db: AsyncSession
218
+ ):
219
+ """Test: git worktree remove fails AND path still registered -> returns False."""
220
+ goal = Goal(id=str(uuid4()), title="Test Goal")
221
+ db.add(goal)
222
+ await db.flush()
223
+
224
+ ticket = Ticket(
225
+ id=str(uuid4()),
226
+ goal_id=goal.id,
227
+ title="Test ticket",
228
+ state="done",
229
+ )
230
+ db.add(ticket)
231
+ await db.flush()
232
+
233
+ with tempfile.TemporaryDirectory() as tmpdir:
234
+ repo_path = Path(tmpdir)
235
+ worktrees_dir = repo_path / ".draft/worktrees"
236
+ worktrees_dir.mkdir(parents=True)
237
+
238
+ # Create actual worktree directory
239
+ worktree_path = worktrees_dir / "test-worktree"
240
+ worktree_path.mkdir()
241
+
242
+ workspace = Workspace(
243
+ id=str(uuid4()),
244
+ ticket_id=ticket.id,
245
+ worktree_path=str(worktree_path),
246
+ branch_name="test-branch",
247
+ created_at=datetime.now(UTC),
248
+ )
249
+ db.add(workspace)
250
+ await db.commit()
251
+
252
+ service = CleanupService(db)
253
+
254
+ # Mock subprocess: worktree remove fails, list shows still registered
255
+ with (
256
+ patch("app.services.cleanup_service.subprocess.run") as mock_run,
257
+ patch("app.services.cleanup_service.shutil.rmtree") as mock_rmtree,
258
+ patch(
259
+ "app.services.cleanup_service.WorkspaceService.get_repo_path",
260
+ return_value=repo_path,
261
+ ),
262
+ ):
263
+
264
+ def run_side_effect(cmd, **kwargs):
265
+ result = MagicMock()
266
+ if cmd[:3] == ["git", "worktree", "remove"]:
267
+ result.returncode = 1
268
+ result.stderr = "error: cannot remove worktree"
269
+ result.stdout = ""
270
+ elif cmd[:3] == ["git", "worktree", "list"]:
271
+ # Return porcelain format showing worktree as registered
272
+ result.returncode = 0
273
+ result.stdout = f"worktree {worktree_path}\nHEAD abc123\nbranch refs/heads/test-branch\n"
274
+ result.stderr = ""
275
+ else:
276
+ result.returncode = 0
277
+ result.stdout = ""
278
+ result.stderr = ""
279
+ return result
280
+
281
+ mock_run.side_effect = run_side_effect
282
+
283
+ result = await service.delete_worktree(
284
+ workspace=workspace,
285
+ ticket_id=ticket.id,
286
+ actor_id="test-actor",
287
+ force=False,
288
+ delete_branch=False,
289
+ )
290
+
291
+ # Assert: Returns False
292
+ assert result is False
293
+
294
+ # Assert: rmtree NOT called (worktree still registered)
295
+ mock_rmtree.assert_not_called()
296
+
297
+ # Assert: cleaned_up_at remains NULL
298
+ await db.refresh(workspace)
299
+ assert workspace.cleaned_up_at is None
300
+
301
+ # Assert: WORKTREE_CLEANUP_FAILED event with still_registered=True
302
+ events_result = await db.execute(
303
+ select(TicketEvent)
304
+ .where(TicketEvent.ticket_id == ticket.id)
305
+ .where(
306
+ TicketEvent.event_type == EventType.WORKTREE_CLEANUP_FAILED.value
307
+ )
308
+ )
309
+ events = events_result.scalars().all()
310
+ assert len(events) == 1
311
+
312
+ payload = json.loads(events[0].payload_json)
313
+ assert payload.get("cleanup_failed") is True
314
+ assert payload.get("still_registered") is True
315
+ assert payload.get("worktree_removed") is False
316
+
317
+ @pytest.mark.asyncio
318
+ async def test_force_true_still_registered_returns_false(self, db: AsyncSession):
319
+ """Test: force=True but still registered -> still returns False.
320
+
321
+ Even with force=True, we cannot safely proceed if the worktree
322
+ is still registered. cleaned_up_at must remain NULL.
323
+ """
324
+ goal = Goal(id=str(uuid4()), title="Test Goal")
325
+ db.add(goal)
326
+ await db.flush()
327
+
328
+ ticket = Ticket(
329
+ id=str(uuid4()),
330
+ goal_id=goal.id,
331
+ title="Test ticket",
332
+ state="done",
333
+ )
334
+ db.add(ticket)
335
+ await db.flush()
336
+
337
+ with tempfile.TemporaryDirectory() as tmpdir:
338
+ repo_path = Path(tmpdir)
339
+ worktrees_dir = repo_path / ".draft/worktrees"
340
+ worktrees_dir.mkdir(parents=True)
341
+
342
+ worktree_path = worktrees_dir / "test-worktree"
343
+ worktree_path.mkdir()
344
+
345
+ workspace = Workspace(
346
+ id=str(uuid4()),
347
+ ticket_id=ticket.id,
348
+ worktree_path=str(worktree_path),
349
+ branch_name="test-branch",
350
+ created_at=datetime.now(UTC),
351
+ )
352
+ db.add(workspace)
353
+ await db.commit()
354
+
355
+ service = CleanupService(db)
356
+
357
+ with (
358
+ patch("app.services.cleanup_service.subprocess.run") as mock_run,
359
+ patch("app.services.cleanup_service.shutil.rmtree") as mock_rmtree,
360
+ patch(
361
+ "app.services.cleanup_service.WorkspaceService.get_repo_path",
362
+ return_value=repo_path,
363
+ ),
364
+ ):
365
+
366
+ def run_side_effect(cmd, **kwargs):
367
+ result = MagicMock()
368
+ if cmd[:3] == ["git", "worktree", "remove"]:
369
+ result.returncode = 1
370
+ result.stderr = "error: cannot remove"
371
+ result.stdout = ""
372
+ elif cmd[:3] == ["git", "worktree", "list"]:
373
+ result.returncode = 0
374
+ result.stdout = f"worktree {worktree_path}\n"
375
+ result.stderr = ""
376
+ else:
377
+ result.returncode = 0
378
+ result.stdout = ""
379
+ result.stderr = ""
380
+ return result
381
+
382
+ mock_run.side_effect = run_side_effect
383
+
384
+ # Call with force=True
385
+ result = await service.delete_worktree(
386
+ workspace=workspace,
387
+ ticket_id=ticket.id,
388
+ actor_id="test-actor",
389
+ force=True, # Force flag!
390
+ delete_branch=False,
391
+ )
392
+
393
+ # Assert: STILL returns False - force cannot override "still registered"
394
+ assert result is False
395
+
396
+ # Assert: rmtree NOT called
397
+ mock_rmtree.assert_not_called()
398
+
399
+ # Assert: cleaned_up_at remains NULL (even with force!)
400
+ await db.refresh(workspace)
401
+ assert workspace.cleaned_up_at is None
402
+
403
+ # Assert: Event has force_used=True + still_registered=True
404
+ events_result = await db.execute(
405
+ select(TicketEvent)
406
+ .where(TicketEvent.ticket_id == ticket.id)
407
+ .where(
408
+ TicketEvent.event_type == EventType.WORKTREE_CLEANUP_FAILED.value
409
+ )
410
+ )
411
+ events = events_result.scalars().all()
412
+ assert len(events) == 1
413
+
414
+ payload = json.loads(events[0].payload_json)
415
+ assert payload.get("force_used") is True
416
+ assert payload.get("still_registered") is True
417
+ assert payload.get("cleanup_failed") is True
@@ -0,0 +1,279 @@
1
+ """Tests for idempotency and rate limiting middleware.
2
+
3
+ These tests verify failure modes and edge cases for the SQLite-backed middleware.
4
+ """
5
+
6
+ import sqlite3
7
+ import time
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+ from fastapi import FastAPI
12
+ from fastapi.testclient import TestClient
13
+
14
+ from app.middleware.idempotency import IdempotencyMiddleware
15
+ from app.middleware.rate_limit import RateLimitMiddleware
16
+
17
+ # =============================================================================
18
+ # Test App Setup
19
+ # =============================================================================
20
+
21
+
22
+ def create_test_app(
23
+ with_idempotency: bool = False,
24
+ with_rate_limit: bool = False,
25
+ rate_limit_budget: int = 100, # Cost-based budget
26
+ rate_limit_window: int = 60,
27
+ ):
28
+ """Create a test FastAPI app with specified middleware."""
29
+ app = FastAPI()
30
+
31
+ @app.post("/goals/{goal_id}/generate-tickets")
32
+ async def generate_tickets(goal_id: str):
33
+ return {"tickets": [], "goal_id": goal_id, "timestamp": time.time()}
34
+
35
+ @app.post("/goals/{goal_id}/reflect-on-tickets")
36
+ async def reflect_on_tickets(goal_id: str):
37
+ return {"quality": "good", "timestamp": time.time()}
38
+
39
+ @app.post("/other")
40
+ async def other_endpoint():
41
+ return {"status": "ok"}
42
+
43
+ if with_rate_limit:
44
+ app.add_middleware(
45
+ RateLimitMiddleware,
46
+ budget=rate_limit_budget,
47
+ window_seconds=rate_limit_window,
48
+ )
49
+
50
+ if with_idempotency:
51
+ app.add_middleware(IdempotencyMiddleware)
52
+
53
+ return app
54
+
55
+
56
+ # =============================================================================
57
+ # Shared SQLite fixture
58
+ # =============================================================================
59
+
60
+
61
+ @pytest.fixture
62
+ def sqlite_test_db(tmp_path):
63
+ """Create a temporary SQLite database with all required tables."""
64
+ db_path = str(tmp_path / "test.db")
65
+ conn = sqlite3.connect(db_path)
66
+ conn.execute("PRAGMA journal_mode=WAL")
67
+ conn.execute("""
68
+ CREATE TABLE idempotency_cache (
69
+ cache_key TEXT PRIMARY KEY,
70
+ lock_value TEXT,
71
+ result_value TEXT,
72
+ lock_expires_at TIMESTAMP,
73
+ result_expires_at TIMESTAMP,
74
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
75
+ )
76
+ """)
77
+ conn.execute("""
78
+ CREATE TABLE rate_limit_entries (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ client_key TEXT NOT NULL,
81
+ cost INTEGER NOT NULL DEFAULT 1,
82
+ recorded_at REAL NOT NULL,
83
+ expires_at REAL NOT NULL
84
+ )
85
+ """)
86
+ conn.execute("""
87
+ CREATE TABLE kv_store (
88
+ key TEXT PRIMARY KEY,
89
+ value TEXT NOT NULL,
90
+ expires_at TIMESTAMP,
91
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
92
+ )
93
+ """)
94
+ conn.commit()
95
+ conn.close()
96
+
97
+ with patch("app.sqlite_kv._DB_PATH", db_path):
98
+ yield db_path
99
+
100
+
101
+ # =============================================================================
102
+ # Idempotency Tests
103
+ # =============================================================================
104
+
105
+
106
+ class TestIdempotencyMiddleware:
107
+ """Tests for IdempotencyMiddleware with SQLite backend."""
108
+
109
+ def test_first_request_executes(self, sqlite_test_db):
110
+ """First request acquires lock via SQLite INSERT OR IGNORE."""
111
+ app = create_test_app(with_idempotency=True)
112
+ client = TestClient(app)
113
+
114
+ response = client.post(
115
+ "/goals/123/generate-tickets",
116
+ json={},
117
+ headers={"Idempotency-Key": "test-1"},
118
+ )
119
+ assert response.status_code == 200
120
+ assert response.headers.get("X-Execution-ID") is not None
121
+
122
+ def test_cached_response_returned(self, sqlite_test_db):
123
+ """Second request with same key returns cached response."""
124
+ app = create_test_app(with_idempotency=True)
125
+ client = TestClient(app)
126
+
127
+ # First request
128
+ response1 = client.post(
129
+ "/goals/123/generate-tickets",
130
+ json={},
131
+ headers={
132
+ "Idempotency-Key": "test-cache",
133
+ "X-Client-ID": "test-client",
134
+ },
135
+ )
136
+ assert response1.status_code == 200
137
+ data1 = response1.json()
138
+
139
+ # Second request - should get cached
140
+ response2 = client.post(
141
+ "/goals/123/generate-tickets",
142
+ json={},
143
+ headers={
144
+ "Idempotency-Key": "test-cache",
145
+ "X-Client-ID": "test-client",
146
+ },
147
+ )
148
+ assert response2.status_code == 200
149
+ assert response2.headers.get("X-Idempotency-Replayed") == "true"
150
+ data2 = response2.json()
151
+ assert data1["timestamp"] == data2["timestamp"]
152
+
153
+ def test_different_body_returns_409(self, sqlite_test_db):
154
+ """Same key + different body returns 409."""
155
+ app = create_test_app(with_idempotency=True)
156
+ client = TestClient(app)
157
+
158
+ # First request
159
+ client.post(
160
+ "/goals/123/generate-tickets",
161
+ json={"body": "original"},
162
+ headers={
163
+ "Idempotency-Key": "test-conflict",
164
+ "X-Client-ID": "test-client",
165
+ },
166
+ )
167
+
168
+ # Second request with different body
169
+ response = client.post(
170
+ "/goals/123/generate-tickets",
171
+ json={"body": "different"},
172
+ headers={
173
+ "Idempotency-Key": "test-conflict",
174
+ "X-Client-ID": "test-client",
175
+ },
176
+ )
177
+ assert response.status_code == 409
178
+
179
+ def test_no_idempotency_key_processes_normally(self, sqlite_test_db):
180
+ """Requests without idempotency key should process normally."""
181
+ app = create_test_app(with_idempotency=True)
182
+ client = TestClient(app)
183
+
184
+ response = client.post(
185
+ "/goals/123/generate-tickets",
186
+ json={},
187
+ )
188
+ assert response.status_code == 200
189
+
190
+ def test_idempotency_key_too_long_returns_400(self):
191
+ """Idempotency key longer than 64 chars should return 400."""
192
+ app = create_test_app(with_idempotency=True)
193
+ client = TestClient(app)
194
+
195
+ response = client.post(
196
+ "/goals/123/generate-tickets",
197
+ json={},
198
+ headers={"Idempotency-Key": "x" * 100},
199
+ )
200
+ assert response.status_code == 400
201
+ assert "too long" in response.json()["detail"]
202
+
203
+ def test_non_idempotent_endpoints_bypass_middleware(self, sqlite_test_db):
204
+ """Endpoints not in IDEMPOTENT_ENDPOINTS should bypass middleware."""
205
+ app = create_test_app(with_idempotency=True)
206
+ client = TestClient(app)
207
+
208
+ response = client.post(
209
+ "/other",
210
+ json={},
211
+ headers={"Idempotency-Key": "test-key"},
212
+ )
213
+ assert response.status_code == 200
214
+
215
+
216
+ # =============================================================================
217
+ # Rate Limit Tests
218
+ # =============================================================================
219
+
220
+
221
+ class TestRateLimitMiddleware:
222
+ """Tests for RateLimitMiddleware with SQLite backend."""
223
+
224
+ def test_rate_limit_allows_within_budget(self, sqlite_test_db):
225
+ """Requests within budget should succeed."""
226
+ app = create_test_app(with_rate_limit=True, rate_limit_budget=100)
227
+ client = TestClient(app)
228
+
229
+ response = client.post(
230
+ "/goals/123/generate-tickets",
231
+ json={},
232
+ )
233
+ assert response.status_code == 200
234
+ assert response.headers.get("X-RateLimit-Limit") == "100"
235
+
236
+ def test_rate_limit_blocks_when_exceeded(self, sqlite_test_db):
237
+ """Requests exceeding budget should get 429."""
238
+ app = create_test_app(
239
+ with_rate_limit=True, rate_limit_budget=5, rate_limit_window=60
240
+ )
241
+ client = TestClient(app)
242
+
243
+ # Make requests until rate limited
244
+ got_429 = False
245
+ for _ in range(10):
246
+ response = client.post(
247
+ "/goals/123/generate-tickets",
248
+ json={},
249
+ )
250
+ if response.status_code == 429:
251
+ got_429 = True
252
+ break
253
+
254
+ assert got_429, "Should have been rate limited"
255
+ data = response.json()
256
+ assert "Rate limit exceeded" in data["detail"]
257
+
258
+ def test_rate_limit_headers_present_on_success(self, sqlite_test_db):
259
+ """Successful requests should include rate limit headers."""
260
+ app = create_test_app(with_rate_limit=True, rate_limit_budget=100)
261
+ client = TestClient(app)
262
+
263
+ response = client.post(
264
+ "/goals/123/generate-tickets",
265
+ json={},
266
+ )
267
+ assert response.status_code == 200
268
+ assert response.headers.get("X-RateLimit-Limit") == "100"
269
+ assert response.headers.get("X-RateLimit-Remaining") is not None
270
+ assert response.headers.get("X-RateLimit-Reset") is not None
271
+
272
+ def test_non_rate_limited_endpoints_bypass_middleware(self, sqlite_test_db):
273
+ """Endpoints not in RATE_LIMITED_ENDPOINTS should bypass middleware."""
274
+ app = create_test_app(with_rate_limit=True)
275
+ client = TestClient(app)
276
+
277
+ response = client.post("/other", json={})
278
+ assert response.status_code == 200
279
+ assert response.headers.get("X-RateLimit-Limit") is None