test_api_e2e.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import pytest
  2. import requests
  3. import time
  4. import os
  5. from datetime import datetime, timezone, timedelta
  6. @pytest.mark.e2e
  7. class TestAuthentication:
  8. def test_invalid_api_key_returns_401(self, api_base_url):
  9. """Invalid API key should return 401 Unauthorized."""
  10. headers = {"Authorization": "Bearer invalid-fake-key-123"}
  11. response = requests.get(f"{api_base_url}/schedules/recurring", headers=headers)
  12. assert response.status_code == 401
  13. def test_no_auth_header_returns_403(self, api_base_url):
  14. """Missing auth header should return 403."""
  15. response = requests.get(f"{api_base_url}/schedules/recurring")
  16. assert response.status_code == 403
  17. def test_api_key_not_in_response(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  18. """API key should never be returned in responses."""
  19. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  20. # Create schedule
  21. payload = {
  22. "agent_id": valid_letta_agent_id,
  23. "api_key": valid_letta_api_key,
  24. "cron": "*/5 * * * *",
  25. "message": "Test message",
  26. "role": "user"
  27. }
  28. response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  29. # API key should not be in response
  30. data = response.json()
  31. assert "api_key" not in data
  32. # Clean up
  33. schedule_id = data["id"]
  34. requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  35. @pytest.mark.e2e
  36. class TestRecurringScheduleCRUD:
  37. def test_create_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  38. """Should successfully create a recurring schedule."""
  39. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  40. payload = {
  41. "agent_id": valid_letta_agent_id,
  42. "api_key": valid_letta_api_key,
  43. "cron": "0 9 * * *",
  44. "message": "Daily morning message",
  45. "role": "user"
  46. }
  47. response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  48. assert response.status_code == 201
  49. data = response.json()
  50. assert "id" in data
  51. assert data["cron"] == "0 9 * * *"
  52. assert data["message"] == "Daily morning message"
  53. # Clean up
  54. requests.delete(f"{api_base_url}/schedules/recurring/{data['id']}", headers=headers)
  55. def test_list_recurring_schedules(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  56. """List should only return schedules for authenticated user."""
  57. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  58. # Create a schedule
  59. payload = {
  60. "agent_id": valid_letta_agent_id,
  61. "api_key": valid_letta_api_key,
  62. "cron": "*/10 * * * *",
  63. "message": "Test",
  64. "role": "user"
  65. }
  66. create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  67. schedule_id = create_response.json()["id"]
  68. # List schedules
  69. response = requests.get(f"{api_base_url}/schedules/recurring", headers=headers)
  70. assert response.status_code == 200
  71. schedules = response.json()
  72. assert isinstance(schedules, list)
  73. # Find our schedule
  74. our_schedule = next((s for s in schedules if s["id"] == schedule_id), None)
  75. assert our_schedule is not None
  76. assert our_schedule["cron"] == "*/10 * * * *"
  77. # Clean up
  78. requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  79. def test_get_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  80. """Get should return specific schedule."""
  81. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  82. # Create schedule
  83. payload = {
  84. "agent_id": valid_letta_agent_id,
  85. "api_key": valid_letta_api_key,
  86. "cron": "0 12 * * *",
  87. "message": "Noon message",
  88. "role": "user"
  89. }
  90. create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  91. schedule_id = create_response.json()["id"]
  92. # Get schedule
  93. response = requests.get(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  94. assert response.status_code == 200
  95. data = response.json()
  96. assert data["id"] == schedule_id
  97. assert data["cron"] == "0 12 * * *"
  98. # Clean up
  99. requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  100. def test_delete_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  101. """Delete should remove schedule."""
  102. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  103. # Create schedule
  104. payload = {
  105. "agent_id": valid_letta_agent_id,
  106. "api_key": valid_letta_api_key,
  107. "cron": "0 0 * * *",
  108. "message": "To be deleted",
  109. "role": "user"
  110. }
  111. create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  112. schedule_id = create_response.json()["id"]
  113. # Delete schedule
  114. delete_response = requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  115. assert delete_response.status_code == 200
  116. # Verify it's gone
  117. get_response = requests.get(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  118. assert get_response.status_code == 404
  119. def test_delete_nonexistent_returns_404(self, api_base_url, valid_letta_api_key):
  120. """Deleting non-existent schedule should return 404."""
  121. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  122. response = requests.delete(f"{api_base_url}/schedules/recurring/fake-uuid-123", headers=headers)
  123. assert response.status_code == 404
  124. @pytest.mark.e2e
  125. class TestOneTimeScheduleCRUD:
  126. def test_create_onetime_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  127. """Should successfully create a one-time schedule."""
  128. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  129. future_time = datetime.now(timezone.utc) + timedelta(hours=1)
  130. payload = {
  131. "agent_id": valid_letta_agent_id,
  132. "api_key": valid_letta_api_key,
  133. "execute_at": future_time.isoformat(),
  134. "message": "Future message",
  135. "role": "user"
  136. }
  137. response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  138. assert response.status_code == 201
  139. data = response.json()
  140. assert "id" in data
  141. assert data["execute_at"] == future_time.isoformat()
  142. # Clean up
  143. requests.delete(f"{api_base_url}/schedules/one-time/{data['id']}", headers=headers)
  144. def test_list_onetime_schedules(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  145. """List should only return user's schedules."""
  146. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  147. future_time = datetime.now(timezone.utc) + timedelta(hours=2)
  148. payload = {
  149. "agent_id": valid_letta_agent_id,
  150. "api_key": valid_letta_api_key,
  151. "execute_at": future_time.isoformat(),
  152. "message": "Test",
  153. "role": "user"
  154. }
  155. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  156. schedule_id = create_response.json()["id"]
  157. # List schedules
  158. response = requests.get(f"{api_base_url}/schedules/one-time", headers=headers)
  159. assert response.status_code == 200
  160. schedules = response.json()
  161. our_schedule = next((s for s in schedules if s["id"] == schedule_id), None)
  162. assert our_schedule is not None
  163. # Clean up
  164. requests.delete(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  165. def test_delete_onetime_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  166. """Delete should remove one-time schedule."""
  167. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  168. future_time = datetime.now(timezone.utc) + timedelta(hours=3)
  169. payload = {
  170. "agent_id": valid_letta_agent_id,
  171. "api_key": valid_letta_api_key,
  172. "execute_at": future_time.isoformat(),
  173. "message": "To be deleted",
  174. "role": "user"
  175. }
  176. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  177. schedule_id = create_response.json()["id"]
  178. # Delete
  179. delete_response = requests.delete(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  180. assert delete_response.status_code == 200
  181. # Verify gone
  182. get_response = requests.get(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  183. assert get_response.status_code == 404
  184. @pytest.mark.e2e
  185. class TestResults:
  186. def test_list_results(self, api_base_url, valid_letta_api_key):
  187. """Should list execution results."""
  188. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  189. response = requests.get(f"{api_base_url}/results", headers=headers)
  190. assert response.status_code == 200
  191. assert isinstance(response.json(), list)
  192. def test_get_result_nonexistent_returns_404(self, api_base_url, valid_letta_api_key):
  193. """Getting non-existent result should return 404."""
  194. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  195. response = requests.get(f"{api_base_url}/results/fake-uuid-123", headers=headers)
  196. assert response.status_code == 404
  197. @pytest.mark.e2e
  198. @pytest.mark.slow
  199. class TestExecution:
  200. def test_past_onetime_executes_immediately(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  201. """One-time schedule in the past should execute within 1 minute."""
  202. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  203. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  204. payload = {
  205. "agent_id": valid_letta_agent_id,
  206. "api_key": valid_letta_api_key,
  207. "execute_at": past_time.isoformat(),
  208. "message": "Should execute immediately",
  209. "role": "user"
  210. }
  211. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  212. schedule_id = create_response.json()["id"]
  213. # Wait up to 90 seconds for execution
  214. max_wait = 90
  215. for i in range(max_wait):
  216. time.sleep(1)
  217. # Check if result exists
  218. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  219. if result_response.status_code == 200:
  220. result = result_response.json()
  221. assert "run_id" in result
  222. assert result["schedule_type"] == "one-time"
  223. print(f"✓ Executed in {i+1} seconds, run_id: {result['run_id']}")
  224. return
  225. pytest.fail(f"Schedule did not execute within {max_wait} seconds")
  226. def test_onetime_schedule_deleted_after_execution(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  227. """One-time schedule should be deleted from filesystem after execution."""
  228. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  229. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  230. payload = {
  231. "agent_id": valid_letta_agent_id,
  232. "api_key": valid_letta_api_key,
  233. "execute_at": past_time.isoformat(),
  234. "message": "Should be deleted after execution",
  235. "role": "user"
  236. }
  237. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  238. schedule_id = create_response.json()["id"]
  239. # Wait for execution
  240. for _ in range(90):
  241. time.sleep(1)
  242. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  243. if result_response.status_code == 200:
  244. break
  245. # Schedule should be deleted
  246. schedule_response = requests.get(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  247. assert schedule_response.status_code == 404
  248. def test_result_persists_after_schedule_deletion(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  249. """Execution result should persist even after schedule is deleted."""
  250. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  251. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  252. payload = {
  253. "agent_id": valid_letta_agent_id,
  254. "api_key": valid_letta_api_key,
  255. "execute_at": past_time.isoformat(),
  256. "message": "Test result persistence",
  257. "role": "user"
  258. }
  259. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  260. schedule_id = create_response.json()["id"]
  261. # Wait for execution
  262. for _ in range(90):
  263. time.sleep(1)
  264. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  265. if result_response.status_code == 200:
  266. result = result_response.json()
  267. break
  268. # Result should still exist
  269. final_result = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  270. assert final_result.status_code == 200
  271. assert "run_id" in final_result.json()
  272. def test_no_duplicate_execution(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  273. """One-time schedule should execute exactly once, even if multiple executors spawn."""
  274. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  275. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  276. payload = {
  277. "agent_id": valid_letta_agent_id,
  278. "api_key": valid_letta_api_key,
  279. "execute_at": past_time.isoformat(),
  280. "message": "Should only execute once",
  281. "role": "user"
  282. }
  283. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  284. schedule_id = create_response.json()["id"]
  285. # Wait for execution
  286. time.sleep(90)
  287. # Check result
  288. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  289. assert result_response.status_code == 200
  290. # There should be exactly one result file, indicating one execution
  291. # (We can't directly verify this without filesystem access, but we can check result exists)
  292. result = result_response.json()
  293. assert result["schedule_id"] == schedule_id
  294. assert "run_id" in result
  295. @pytest.mark.e2e
  296. class TestAuthorization:
  297. def test_user_isolation_list(self, api_base_url, valid_letta_api_key):
  298. """Users should only see their own schedules in list."""
  299. # This test requires a second valid API key
  300. # Skip if not available
  301. second_key = os.getenv("LETTA_API_KEY_2")
  302. if not second_key:
  303. pytest.skip("LETTA_API_KEY_2 not set for multi-user testing")
  304. headers1 = {"Authorization": f"Bearer {valid_letta_api_key}"}
  305. headers2 = {"Authorization": f"Bearer {second_key}"}
  306. # User 1's list should not contain User 2's schedules
  307. response1 = requests.get(f"{api_base_url}/schedules/recurring", headers=headers1)
  308. response2 = requests.get(f"{api_base_url}/schedules/recurring", headers=headers2)
  309. schedules1 = response1.json()
  310. schedules2 = response2.json()
  311. # Lists should be independent (no overlap)
  312. ids1 = {s["id"] for s in schedules1}
  313. ids2 = {s["id"] for s in schedules2}
  314. assert ids1.isdisjoint(ids2), "Users should not see each other's schedules"