test_s3.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. import datetime
  2. import json
  3. from mock import patch, MagicMock, call, ANY
  4. from io import BytesIO
  5. import pytest
  6. from botocore.exceptions import ClientError
  7. from backend.ecs_tasks.delete_files.s3 import (
  8. delete_old_versions,
  9. DeleteOldVersionsError,
  10. fetch_job_manifest,
  11. fetch_manifest,
  12. get_requester_payment,
  13. get_grantees,
  14. get_object_acl,
  15. get_object_info,
  16. get_object_tags,
  17. IntegrityCheckFailedError,
  18. rollback_object_version,
  19. save,
  20. s3transfer,
  21. validate_bucket_versioning,
  22. verify_object_versions_integrity,
  23. )
  24. pytestmark = [pytest.mark.unit, pytest.mark.ecs_tasks]
  25. def get_list_object_versions_error():
  26. return ClientError(
  27. {
  28. "Error": {
  29. "Code": "InvalidArgument",
  30. "Message": "Invalid version id specified",
  31. }
  32. },
  33. "ListObjectVersions",
  34. )
  35. def test_it_validates_bucket_versioning():
  36. validate_bucket_versioning.cache_clear()
  37. client = MagicMock()
  38. client.get_bucket_versioning.return_value = {"Status": "Enabled"}
  39. assert validate_bucket_versioning(client, "bucket")
  40. def test_it_throws_when_versioning_disabled():
  41. validate_bucket_versioning.cache_clear()
  42. client = MagicMock()
  43. client.get_bucket_versioning.return_value = {}
  44. with pytest.raises(ValueError) as e:
  45. validate_bucket_versioning(client, "bucket")
  46. assert e.value.args[0] == "Bucket bucket does not have versioning enabled"
  47. def test_it_throws_when_versioning_suspended():
  48. validate_bucket_versioning.cache_clear()
  49. client = MagicMock()
  50. client.get_bucket_versioning.return_value = {"Status": "Suspended"}
  51. with pytest.raises(ValueError) as e:
  52. validate_bucket_versioning(client, "bucket")
  53. assert e.value.args[0] == "Bucket bucket does not have versioning enabled"
  54. def test_it_throws_when_mfa_delete_enabled():
  55. validate_bucket_versioning.cache_clear()
  56. client = MagicMock()
  57. client.get_bucket_versioning.return_value = {
  58. "Status": "Enabled",
  59. "MFADelete": "Enabled",
  60. }
  61. with pytest.raises(ValueError) as e:
  62. validate_bucket_versioning(client, "bucket")
  63. assert e.value.args[0] == "Bucket bucket has MFA Delete enabled"
  64. def test_it_returns_requester_pays():
  65. get_requester_payment.cache_clear()
  66. client = MagicMock()
  67. client.get_bucket_request_payment.return_value = {"Payer": "Requester"}
  68. assert (
  69. {"RequestPayer": "requester"},
  70. {"Payer": "Requester"},
  71. ) == get_requester_payment(client, "bucket")
  72. def test_it_returns_empty_for_non_requester_pays():
  73. get_requester_payment.cache_clear()
  74. client = MagicMock()
  75. client.get_bucket_request_payment.return_value = {"Payer": "Owner"}
  76. assert ({}, {"Payer": "Owner"}) == get_requester_payment(client, "bucket")
  77. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  78. def test_it_returns_standard_info(mock_requester):
  79. get_object_info.cache_clear()
  80. client = MagicMock()
  81. mock_requester.return_value = {}, {}
  82. stub = {
  83. "CacheControl": "cache",
  84. "ContentDisposition": "content_disposition",
  85. "ContentEncoding": "content_encoding",
  86. "ContentLanguage": "content_language",
  87. "ContentType": "ContentType",
  88. "Expires": "123",
  89. "Metadata": {"foo": "bar"},
  90. "ServerSideEncryption": "see",
  91. "StorageClass": "STANDARD",
  92. "SSECustomerAlgorithm": "aws:kms",
  93. "SSEKMSKeyId": "1234",
  94. "WebsiteRedirectLocation": "test",
  95. }
  96. client.head_object.return_value = stub
  97. assert stub == get_object_info(client, "bucket", "key")[0]
  98. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  99. def test_it_strips_empty_standard_info(mock_requester):
  100. get_object_info.cache_clear()
  101. client = MagicMock()
  102. mock_requester.return_value = {}, {}
  103. stub = {
  104. "CacheControl": "cache",
  105. "ContentDisposition": "content_disposition",
  106. "ContentEncoding": "content_encoding",
  107. "ContentLanguage": "content_language",
  108. "ContentType": "ContentType",
  109. "Expires": "123",
  110. "Metadata": {"foo": "bar"},
  111. "ServerSideEncryption": "see",
  112. "StorageClass": "STANDARD",
  113. "SSECustomerAlgorithm": "aws:kms",
  114. "SSEKMSKeyId": "1234",
  115. "WebsiteRedirectLocation": None,
  116. }
  117. client.head_object.return_value = stub
  118. assert {
  119. "CacheControl": "cache",
  120. "ContentDisposition": "content_disposition",
  121. "ContentEncoding": "content_encoding",
  122. "ContentLanguage": "content_language",
  123. "ContentType": "ContentType",
  124. "Expires": "123",
  125. "Metadata": {"foo": "bar"},
  126. "ServerSideEncryption": "see",
  127. "StorageClass": "STANDARD",
  128. "SSECustomerAlgorithm": "aws:kms",
  129. "SSEKMSKeyId": "1234",
  130. } == get_object_info(client, "bucket", "key")[0]
  131. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  132. def test_it_handles_versions_for_get_info(mock_requester):
  133. get_object_info.cache_clear()
  134. client = MagicMock()
  135. mock_requester.return_value = {}, {}
  136. client.head_object.return_value = {}
  137. get_object_info(client, "bucket", "key")
  138. client.head_object.assert_called_with(Bucket="bucket", Key="key")
  139. get_object_info(client, "bucket", "key", "abc123")
  140. client.head_object.assert_called_with(
  141. Bucket="bucket", Key="key", VersionId="abc123"
  142. )
  143. def test_it_gets_tagging_args():
  144. get_object_tags.cache_clear()
  145. client = MagicMock()
  146. client.get_object_tagging.return_value = {
  147. "TagSet": [{"Key": "a", "Value": "b"}, {"Key": "c", "Value": "d"}]
  148. }
  149. assert {"Tagging": "a=b&c=d",} == get_object_tags(
  150. client, "bucket", "key"
  151. )[0]
  152. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  153. def test_it_handles_versions_for_get_tagging(mock_requester):
  154. get_object_info.cache_clear()
  155. client = MagicMock()
  156. mock_requester.return_value = {}, {}
  157. client.get_object_tagging.return_value = {"TagSet": []}
  158. get_object_tags(client, "bucket", "key")
  159. client.get_object_tagging.assert_called_with(Bucket="bucket", Key="key")
  160. get_object_tags(client, "bucket", "key", "abc123")
  161. client.get_object_tagging.assert_called_with(
  162. Bucket="bucket", Key="key", VersionId="abc123"
  163. )
  164. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  165. def test_it_gets_acl_args(mock_requester):
  166. get_object_acl.cache_clear()
  167. client = MagicMock()
  168. mock_requester.return_value = {}, {}
  169. client.get_object_acl.return_value = {
  170. "Owner": {"ID": "a"},
  171. "Grants": [
  172. {"Grantee": {"ID": "b", "Type": "CanonicalUser"}, "Permission": "READ"},
  173. {"Grantee": {"ID": "c", "Type": "CanonicalUser"}, "Permission": "READ_ACP"},
  174. ],
  175. }
  176. assert {
  177. "GrantFullControl": "id=a",
  178. "GrantRead": "id=b",
  179. "GrantReadACP": "id=c",
  180. } == get_object_acl(client, "bucket", "key")[0]
  181. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  182. def test_it_handles_versions_for_get_acl(mock_requester):
  183. get_object_info.cache_clear()
  184. client = MagicMock()
  185. mock_requester.return_value = {}, {}
  186. client.get_object_tagging.return_value = {
  187. "Owner": {"ID": "a"},
  188. "Grants": [
  189. {"Grantee": {"ID": "b", "Type": "CanonicalUser"}, "Permission": "READ"},
  190. {"Grantee": {"ID": "c", "Type": "CanonicalUser"}, "Permission": "READ_ACP"},
  191. ],
  192. }
  193. get_object_acl(client, "bucket", "key")
  194. client.get_object_acl.assert_called_with(Bucket="bucket", Key="key")
  195. get_object_acl(client, "bucket", "key", "abc123")
  196. client.get_object_acl.assert_called_with(
  197. Bucket="bucket", Key="key", VersionId="abc123"
  198. )
  199. def test_it_gets_grantees_by_type():
  200. acl = {
  201. "Owner": {"ID": "owner_id"},
  202. "Grants": [
  203. {
  204. "Grantee": {"ID": "grantee1", "Type": "CanonicalUser"},
  205. "Permission": "FULL_CONTROL",
  206. },
  207. {
  208. "Grantee": {"ID": "grantee2", "Type": "CanonicalUser"},
  209. "Permission": "FULL_CONTROL",
  210. },
  211. {
  212. "Grantee": {
  213. "EmailAddress": "grantee3",
  214. "Type": "AmazonCustomerByEmail",
  215. },
  216. "Permission": "READ",
  217. },
  218. {"Grantee": {"URI": "grantee4", "Type": "Group"}, "Permission": "WRITE"},
  219. {
  220. "Grantee": {"ID": "grantee5", "Type": "CanonicalUser"},
  221. "Permission": "READ_ACP",
  222. },
  223. {
  224. "Grantee": {"ID": "grantee6", "Type": "CanonicalUser"},
  225. "Permission": "WRITE_ACP",
  226. },
  227. ],
  228. }
  229. assert {"id=grantee1", "id=grantee2"} == get_grantees(acl, "FULL_CONTROL")
  230. assert {"emailAddress=grantee3"} == get_grantees(acl, "READ")
  231. assert {"uri=grantee4"} == get_grantees(acl, "WRITE")
  232. assert {"id=grantee5"} == get_grantees(acl, "READ_ACP")
  233. assert {"id=grantee6"} == get_grantees(acl, "WRITE_ACP")
  234. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  235. @patch("backend.ecs_tasks.delete_files.s3.get_object_info")
  236. @patch("backend.ecs_tasks.delete_files.s3.get_object_tags")
  237. @patch("backend.ecs_tasks.delete_files.s3.get_object_acl")
  238. @patch("backend.ecs_tasks.delete_files.s3.get_grantees")
  239. def test_it_applies_settings_when_saving(
  240. mock_grantees, mock_acl, mock_tagging, mock_standard, mock_requester
  241. ):
  242. mock_client = MagicMock()
  243. mock_requester.return_value = {"RequestPayer": "requester"}, {"Payer": "Requester"}
  244. mock_standard.return_value = ({"Expires": "123", "Metadata": {}}, {})
  245. mock_tagging.return_value = (
  246. {"Tagging": "a=b"},
  247. {"TagSet": [{"Key": "a", "Value": "b"}]},
  248. )
  249. mock_acl.return_value = (
  250. {
  251. "GrantFullControl": "id=abc",
  252. "GrantRead": "id=123",
  253. },
  254. {
  255. "Owner": {"ID": "owner_id"},
  256. "Grants": [
  257. {
  258. "Grantee": {"ID": "abc", "Type": "CanonicalUser"},
  259. "Permission": "FULL_CONTROL",
  260. },
  261. {
  262. "Grantee": {"ID": "123", "Type": "CanonicalUser"},
  263. "Permission": "READ",
  264. },
  265. ],
  266. },
  267. )
  268. mock_grantees.return_value = ""
  269. buf = BytesIO()
  270. mock_client.upload_fileobj.return_value = {"VersionId": "abc123"}
  271. resp = save(mock_client, buf, "bucket", "key", {}, "abc123")
  272. mock_client.upload_fileobj.assert_called_with(
  273. buf,
  274. "bucket",
  275. "key",
  276. ExtraArgs={
  277. "RequestPayer": "requester",
  278. "Expires": "123",
  279. "Metadata": {},
  280. "Tagging": "a=b",
  281. "GrantFullControl": "id=abc",
  282. "GrantRead": "id=123",
  283. },
  284. )
  285. assert "abc123" == resp
  286. mock_client.put_object_acl.assert_not_called()
  287. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  288. @patch("backend.ecs_tasks.delete_files.s3.get_object_info")
  289. @patch("backend.ecs_tasks.delete_files.s3.get_object_tags")
  290. @patch("backend.ecs_tasks.delete_files.s3.get_object_acl")
  291. @patch("backend.ecs_tasks.delete_files.s3.get_grantees")
  292. def test_it_passes_through_version(
  293. mock_grantees, mock_acl, mock_tagging, mock_standard, mock_requester
  294. ):
  295. mock_client = MagicMock()
  296. mock_requester.return_value = {}, {}
  297. mock_standard.return_value = ({}, {})
  298. mock_tagging.return_value = ({}, {})
  299. mock_acl.return_value = ({}, {})
  300. mock_grantees.return_value = ""
  301. buf = BytesIO()
  302. save(mock_client, buf, "bucket", "key", {}, "abc123")
  303. mock_acl.assert_called_with(mock_client, "bucket", "key", "abc123")
  304. mock_tagging.assert_called_with(mock_client, "bucket", "key", "abc123")
  305. mock_standard.assert_called_with(mock_client, "bucket", "key", "abc123")
  306. @patch("backend.ecs_tasks.delete_files.s3.get_requester_payment")
  307. @patch("backend.ecs_tasks.delete_files.s3.get_object_info")
  308. @patch("backend.ecs_tasks.delete_files.s3.get_object_tags")
  309. @patch("backend.ecs_tasks.delete_files.s3.get_object_acl")
  310. @patch("backend.ecs_tasks.delete_files.s3.get_grantees")
  311. def test_it_restores_write_permissions(
  312. mock_grantees, mock_acl, mock_tagging, mock_standard, mock_requester
  313. ):
  314. mock_client = MagicMock()
  315. mock_requester.return_value = {}, {}
  316. mock_standard.return_value = ({}, {})
  317. mock_tagging.return_value = ({}, {})
  318. mock_acl.return_value = (
  319. {
  320. "GrantFullControl": "id=abc",
  321. },
  322. {
  323. "Owner": {"ID": "owner_id"},
  324. "Grants": [
  325. {
  326. "Grantee": {"ID": "abc", "Type": "CanonicalUser"},
  327. "Permission": "FULL_CONTROL",
  328. },
  329. {
  330. "Grantee": {"ID": "123", "Type": "CanonicalUser"},
  331. "Permission": "WRITE",
  332. },
  333. ],
  334. },
  335. )
  336. mock_grantees.return_value = {"id=123"}
  337. buf = BytesIO()
  338. mock_client.upload_fileobj.return_value = {"VersionId": "new_version123"}
  339. save(mock_client, buf, "bucket", "key", "abc123")
  340. mock_client.put_object_acl.assert_called_with(
  341. Bucket="bucket",
  342. Key="key",
  343. VersionId="new_version123",
  344. GrantFullControl="id=abc",
  345. GrantWrite="id=123",
  346. )
  347. def test_it_verifies_integrity_happy_path():
  348. s3_mock = MagicMock()
  349. s3_mock.list_object_versions.return_value = {
  350. "VersionIdMarker": "v7",
  351. "Versions": [{"VersionId": "v6", "ETag": "a"}],
  352. }
  353. result = verify_object_versions_integrity(
  354. s3_mock, "bucket", "requirements.txt", "v6", "v7"
  355. )
  356. assert result
  357. s3_mock.list_object_versions.assert_called_with(
  358. Bucket="bucket",
  359. Prefix="requirements.txt",
  360. VersionIdMarker="v7",
  361. KeyMarker="requirements.txt",
  362. MaxKeys=1,
  363. )
  364. def test_it_fails_integrity_when_delete_marker_between():
  365. s3_mock = MagicMock()
  366. s3_mock.list_object_versions.return_value = {
  367. "VersionIdMarker": "v7",
  368. "Versions": [],
  369. "DeleteMarkers": [{"VersionId": "v6"}],
  370. }
  371. with pytest.raises(IntegrityCheckFailedError) as e:
  372. result = verify_object_versions_integrity(
  373. s3_mock, "bucket", "requirements.txt", "v5", "v7"
  374. )
  375. assert e.value.args == (
  376. "A delete marker (v6) was detected for the given object between read and write operations (v5 and v7).",
  377. s3_mock,
  378. "bucket",
  379. "requirements.txt",
  380. "v7",
  381. )
  382. def test_it_fails_integrity_when_other_version_between():
  383. s3_mock = MagicMock()
  384. s3_mock.list_object_versions.return_value = {
  385. "VersionIdMarker": "v7",
  386. "Versions": [{"VersionId": "v6", "ETag": "a"}],
  387. }
  388. with pytest.raises(IntegrityCheckFailedError) as e:
  389. result = verify_object_versions_integrity(
  390. s3_mock, "bucket", "requirements.txt", "v5", "v7"
  391. )
  392. assert e.value.args == (
  393. "A version (v6) was detected for the given object between read and write operations (v5 and v7).",
  394. s3_mock,
  395. "bucket",
  396. "requirements.txt",
  397. "v7",
  398. )
  399. def test_it_fails_integrity_when_no_other_version_before():
  400. s3_mock = MagicMock()
  401. s3_mock.list_object_versions.return_value = {
  402. "VersionIdMarker": "v7",
  403. "Versions": [],
  404. }
  405. with pytest.raises(IntegrityCheckFailedError) as e:
  406. result = verify_object_versions_integrity(
  407. s3_mock, "bucket", "requirements.txt", "v5", "v7"
  408. )
  409. assert e.value.args == (
  410. "Previous version (v5) has been deleted.",
  411. s3_mock,
  412. "bucket",
  413. "requirements.txt",
  414. "v7",
  415. )
  416. @patch("time.sleep")
  417. def test_it_errors_when_version_to_not_found_after_retries(sleep_mock):
  418. s3_mock = MagicMock()
  419. s3_mock.list_object_versions.side_effect = get_list_object_versions_error()
  420. with pytest.raises(ClientError) as e:
  421. result = verify_object_versions_integrity(
  422. s3_mock, "bucket", "requirements.txt", "v7", "v8"
  423. )
  424. assert sleep_mock.call_args_list == [call(2), call(4), call(8), call(16), call(32)]
  425. assert (
  426. e.value.args[0]
  427. == "An error occurred (InvalidArgument) when calling the ListObjectVersions operation: Invalid version id specified"
  428. )
  429. @patch("backend.ecs_tasks.delete_files.s3.paginate")
  430. def test_it_deletes_old_versions(paginate_mock):
  431. s3_mock = MagicMock()
  432. paginate_mock.return_value = iter(
  433. [
  434. (
  435. {
  436. "VersionId": "v1",
  437. "LastModified": datetime.datetime.now()
  438. - datetime.timedelta(minutes=4),
  439. },
  440. {
  441. "VersionId": "d2",
  442. "LastModified": datetime.datetime.now()
  443. - datetime.timedelta(minutes=3),
  444. },
  445. ),
  446. (
  447. {
  448. "VersionId": "v3",
  449. "LastModified": datetime.datetime.now()
  450. - datetime.timedelta(minutes=2),
  451. },
  452. None,
  453. ),
  454. ]
  455. )
  456. delete_old_versions(s3_mock, "bucket", "key", "v4")
  457. paginate_mock.assert_called_with(
  458. s3_mock,
  459. s3_mock.list_object_versions,
  460. ["Versions", "DeleteMarkers"],
  461. Bucket="bucket",
  462. Prefix="key",
  463. VersionIdMarker="v4",
  464. KeyMarker="key",
  465. )
  466. s3_mock.delete_objects.assert_called_with(
  467. Bucket="bucket",
  468. Delete={
  469. "Objects": [
  470. {"Key": "key", "VersionId": "v1"},
  471. {"Key": "key", "VersionId": "d2"},
  472. {"Key": "key", "VersionId": "v3"},
  473. ],
  474. "Quiet": True,
  475. },
  476. )
  477. @patch("backend.ecs_tasks.delete_files.s3.paginate")
  478. def test_it_handles_high_old_version_count(paginate_mock):
  479. s3_mock = MagicMock()
  480. paginate_mock.return_value = iter(
  481. [
  482. (
  483. {
  484. "VersionId": "v{}".format(i),
  485. "LastModified": datetime.datetime.now()
  486. + datetime.timedelta(minutes=i),
  487. },
  488. None,
  489. )
  490. for i in range(1, 1501)
  491. ]
  492. )
  493. delete_old_versions(s3_mock, "bucket", "key", "v0")
  494. paginate_mock.assert_called_with(
  495. s3_mock,
  496. s3_mock.list_object_versions,
  497. ["Versions", "DeleteMarkers"],
  498. Bucket="bucket",
  499. Prefix="key",
  500. VersionIdMarker="v0",
  501. KeyMarker="key",
  502. )
  503. assert 2 == s3_mock.delete_objects.call_count
  504. assert {
  505. "Bucket": "bucket",
  506. "Delete": {
  507. "Objects": [
  508. {"Key": "key", "VersionId": "v{}".format(i)} for i in range(1, 1001)
  509. ],
  510. "Quiet": True,
  511. },
  512. } == s3_mock.delete_objects.call_args_list[0][1]
  513. assert {
  514. "Bucket": "bucket",
  515. "Delete": {
  516. "Objects": [
  517. {"Key": "key", "VersionId": "v{}".format(i)} for i in range(1001, 1501)
  518. ],
  519. "Quiet": True,
  520. },
  521. } == s3_mock.delete_objects.call_args_list[1][1]
  522. @patch("backend.ecs_tasks.delete_files.s3.paginate")
  523. def test_it_retries_for_deletion_errors(paginate_mock):
  524. s3_mock = MagicMock()
  525. paginate_mock.return_value = iter(
  526. [
  527. (
  528. {
  529. "VersionId": "v1",
  530. "LastModified": datetime.datetime.now()
  531. - datetime.timedelta(minutes=4),
  532. },
  533. {
  534. "VersionId": "v2",
  535. "LastModified": datetime.datetime.now()
  536. - datetime.timedelta(minutes=3),
  537. },
  538. ),
  539. (
  540. {
  541. "VersionId": "v3",
  542. "LastModified": datetime.datetime.now()
  543. - datetime.timedelta(minutes=2),
  544. },
  545. None,
  546. ),
  547. ]
  548. )
  549. s3_mock.delete_objects.side_effect = [
  550. {
  551. "Errors": [
  552. {"VersionId": "v1", "Key": "key", "Message": "InternalServerError"}
  553. ]
  554. },
  555. {
  556. "Errors": [],
  557. },
  558. ]
  559. delete_old_versions(s3_mock, "bucket", "key", "v4")
  560. assert s3_mock.delete_objects.call_count == 2
  561. @patch("backend.ecs_tasks.delete_files.s3.paginate")
  562. @patch("backend.ecs_tasks.delete_files.s3.delete_s3_objects")
  563. def test_it_raises_for_deletion_errors(delete_s3_objects_mock, paginate_mock):
  564. s3_mock = MagicMock()
  565. paginate_mock.return_value = iter(
  566. [
  567. (
  568. {
  569. "VersionId": "v1",
  570. "LastModified": datetime.datetime.now()
  571. - datetime.timedelta(minutes=4),
  572. },
  573. {
  574. "VersionId": "v2",
  575. "LastModified": datetime.datetime.now()
  576. - datetime.timedelta(minutes=3),
  577. },
  578. ),
  579. (
  580. {
  581. "VersionId": "v3",
  582. "LastModified": datetime.datetime.now()
  583. - datetime.timedelta(minutes=2),
  584. },
  585. None,
  586. ),
  587. ]
  588. )
  589. delete_s3_objects_mock.return_value = {
  590. "Errors": [{"VersionId": "v1", "Key": "key", "Message": "Version not found"}]
  591. }
  592. with pytest.raises(DeleteOldVersionsError):
  593. delete_old_versions(s3_mock, "bucket", "key", "v4")
  594. @patch("backend.ecs_tasks.delete_files.s3.paginate")
  595. def test_it_handles_client_errors_as_deletion_errors(paginate_mock):
  596. s3_mock = MagicMock()
  597. paginate_mock.side_effect = get_list_object_versions_error()
  598. with pytest.raises(DeleteOldVersionsError):
  599. delete_old_versions(s3_mock, "bucket", "key", "v3")
  600. def test_it_deletes_new_version_during_rollback():
  601. s3_mock = MagicMock()
  602. s3_mock.delete_object.return_value = "result"
  603. mock_callback = MagicMock()
  604. result = rollback_object_version(
  605. s3_mock, "bucket", "requirements.txt", "version23", on_error=mock_callback
  606. )
  607. assert result == "result"
  608. s3_mock.delete_object.assert_called_with(
  609. Bucket="bucket", Key="requirements.txt", VersionId="version23"
  610. )
  611. mock_callback.assert_not_called()
  612. def test_it_handles_error_for_client_error():
  613. s3_mock = MagicMock()
  614. s3_mock.delete_object.side_effect = ClientError({}, "DeleteObject")
  615. mock_callback = MagicMock()
  616. result = rollback_object_version(
  617. s3_mock, "bucket", "requirements.txt", "version23", on_error=mock_callback
  618. )
  619. mock_callback.assert_called_with(
  620. "ClientError: An error occurred (Unknown) when calling the DeleteObject "
  621. "operation: Unknown. Version rollback caused by version integrity conflict "
  622. "failed"
  623. )
  624. def test_it_handles_error_for_generic_errors():
  625. s3_mock = MagicMock()
  626. s3_mock.delete_object.side_effect = RuntimeError("Some issue")
  627. mock_callback = MagicMock()
  628. result = rollback_object_version(
  629. s3_mock, "bucket", "requirements.txt", "version23", on_error=mock_callback
  630. )
  631. mock_callback.assert_called_with(
  632. "Unknown error: Some issue. Version rollback caused by version integrity "
  633. "conflict failed"
  634. )
  635. @patch("backend.ecs_tasks.delete_files.s3.fetch_job_manifest")
  636. def test_it_caches_manifests(mock_fetch):
  637. fetch_manifest.cache_clear()
  638. fetch_manifest("s3://path/to/manifest1.json")
  639. fetch_manifest("s3://path/to/manifest1.json")
  640. fetch_manifest("s3://path/to/manifest2.json")
  641. assert mock_fetch.call_count == 2
  642. mock_fetch.assert_has_calls(
  643. [call("s3://path/to/manifest1.json"), call("s3://path/to/manifest2.json")],
  644. any_order=True,
  645. )
  646. def test_s3transfer_locked_version():
  647. """
  648. https://github.com/boto/s3transfer/issues/82#issuecomment-837971614
  649. We have a monkey patch in place to allow us using boto3's upload_fileobj
  650. when we write back to S3. The issue is that while the method offers a nice
  651. wrapper around file operations, such as implementing multipart upload only
  652. when needed, we don't get the VersionId back as we would by using
  653. put_object. This monkey patch is in place while we wait some pull requests
  654. to be merged. In the meanwhile, here is a test that allow us to notice
  655. any change on the version we use on s3transfer, in order to add extra
  656. protection against automated library upgrade PRs that may silently introduce
  657. issues.
  658. """
  659. assert s3transfer.__version__ == "0.6.0"
  660. def test_s3transfer_put_object_monkeypatch_returns_response():
  661. put_object_task = s3transfer.upload.PutObjectTask(MagicMock())
  662. client_mock = MagicMock()
  663. client_mock.put_object.return_value = "result"
  664. file_mock = MagicMock()
  665. file_mock.__enter__.return_value = b"123"
  666. resp = put_object_task._main(client_mock, file_mock, "bucket", "key", {})
  667. client_mock.put_object.assert_called_with(Bucket="bucket", Key="key", Body=b"123")
  668. assert resp == "result"
  669. def test_s3transfer_complete_multipart_monkeypatch_returns_response():
  670. cmpu_task = s3transfer.upload.CompleteMultipartUploadTask(MagicMock())
  671. client_mock = MagicMock()
  672. client_mock.complete_multipart_upload.return_value = "cmpu_result"
  673. resp = cmpu_task._main(client_mock, "bucket", "key", "id", [{}], {})
  674. client_mock.complete_multipart_upload.assert_called_with(
  675. Bucket="bucket", Key="key", UploadId="id", MultipartUpload={"Parts": [{}]}
  676. )
  677. assert resp == "cmpu_result"