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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- 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
|