|
18 | 18 | from test.helper_test import uploader_response_mock, SUFFIX, TEST_IMAGE, get_params, get_headers, TEST_ICON, TEST_DOC, \ |
19 | 19 | REMOTE_TEST_IMAGE, UTC, populate_large_file, TEST_UNICODE_IMAGE, get_uri, get_method, get_param, \ |
20 | 20 | cleanup_test_resources_by_tag, cleanup_test_transformation, cleanup_test_resources, EVAL_STR, ON_SUCCESS_STR, \ |
21 | | - URLLIB3_REQUEST, patch, retry_assertion, CldTestCase |
| 21 | + URLLIB3_REQUEST, patch, retry_assertion, CldTestCase, http_response_mock |
22 | 22 | from test.test_utils import TEST_ID, TEST_FOLDER |
23 | 23 |
|
24 | 24 | MOCK_RESPONSE = uploader_response_mock() |
@@ -837,6 +837,190 @@ def test_upload_large_file_io(self): |
837 | 837 | self.assertEqual(resource["width"], LARGE_FILE_WIDTH) |
838 | 838 | self.assertEqual(resource["height"], LARGE_FILE_HEIGHT) |
839 | 839 |
|
| 840 | + _OAUTH_CHUNK_SIZE = 4096 |
| 841 | + _OAUTH_FILE_SIZE = _OAUTH_CHUNK_SIZE * 3 |
| 842 | + |
| 843 | + @staticmethod |
| 844 | + def _oauth_part_response(public_id="test_public_id"): |
| 845 | + return {"public_id": public_id, "done": True} |
| 846 | + |
| 847 | + def _run_upload_large_with_oauth(self, side_effect, chunk_size=None): |
| 848 | + """Runs upload_large over a 3-chunk in-memory file with upload_large_part mocked""" |
| 849 | + chunk_size = chunk_size or self._OAUTH_CHUNK_SIZE |
| 850 | + with io.BytesIO() as temp_file: |
| 851 | + populate_large_file(temp_file, self._OAUTH_FILE_SIZE) |
| 852 | + with patch("cloudinary.uploader.upload_large_part") as part_mock: |
| 853 | + part_mock.side_effect = side_effect |
| 854 | + result = uploader.upload_large(temp_file, chunk_size=chunk_size, |
| 855 | + tags=[UNIQUE_TAG], resource_type="image") |
| 856 | + return result, part_mock |
| 857 | + |
| 858 | + def test_upload_large_oauth_resume_on_mid_stream_401(self): |
| 859 | + """Should refresh once and resume the same upload when a chunk gets a 401""" |
| 860 | + cloudinary.config(oauth_token="expired-token") |
| 861 | + |
| 862 | + rejected_tokens = [] |
| 863 | + |
| 864 | + def refresh(rejected): |
| 865 | + rejected_tokens.append(rejected) |
| 866 | + cloudinary.config(oauth_token="fresh-token") |
| 867 | + |
| 868 | + cloudinary.config().oauth_token_refresh_callback = refresh |
| 869 | + |
| 870 | + calls = {"n": 0} |
| 871 | + |
| 872 | + def side_effect(file, http_headers=None, **options): |
| 873 | + calls["n"] += 1 |
| 874 | + if calls["n"] == 2: |
| 875 | + raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401") |
| 876 | + return self._oauth_part_response() |
| 877 | + |
| 878 | + result, part_mock = self._run_upload_large_with_oauth(side_effect) |
| 879 | + |
| 880 | + self.assertEqual(rejected_tokens, ["expired-token"]) |
| 881 | + self.assertEqual(part_mock.call_count, 4) |
| 882 | + |
| 883 | + # The retry must reuse the failed chunk's X-Unique-Upload-Id and Content-Range to resume. |
| 884 | + upload_ids = [c[1]["http_headers"]["X-Unique-Upload-Id"] for c in part_mock.call_args_list] |
| 885 | + self.assertEqual(len(set(upload_ids)), 1) |
| 886 | + ranges = [c[1]["http_headers"]["Content-Range"] for c in part_mock.call_args_list] |
| 887 | + self.assertEqual(ranges[1], ranges[2]) |
| 888 | + |
| 889 | + self.assertEqual(result, self._oauth_part_response()) |
| 890 | + |
| 891 | + def test_upload_large_oauth_resume_on_first_chunk_401(self): |
| 892 | + """Should refresh and resume when the first chunk (no public_id yet) gets a 401""" |
| 893 | + cloudinary.config(oauth_token="expired-token") |
| 894 | + |
| 895 | + refresh_calls = {"n": 0} |
| 896 | + |
| 897 | + def refresh(rejected): |
| 898 | + refresh_calls["n"] += 1 |
| 899 | + cloudinary.config(oauth_token="fresh-token") |
| 900 | + |
| 901 | + cloudinary.config().oauth_token_refresh_callback = refresh |
| 902 | + |
| 903 | + calls = {"n": 0} |
| 904 | + |
| 905 | + def side_effect(file, http_headers=None, **options): |
| 906 | + calls["n"] += 1 |
| 907 | + if calls["n"] == 1: |
| 908 | + self.assertIsNone(options.get("public_id")) |
| 909 | + raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401") |
| 910 | + return self._oauth_part_response() |
| 911 | + |
| 912 | + result, part_mock = self._run_upload_large_with_oauth(side_effect) |
| 913 | + |
| 914 | + self.assertEqual(refresh_calls["n"], 1) |
| 915 | + self.assertEqual(part_mock.call_count, 4) |
| 916 | + upload_ids = [c[1]["http_headers"]["X-Unique-Upload-Id"] for c in part_mock.call_args_list] |
| 917 | + self.assertEqual(len(set(upload_ids)), 1) |
| 918 | + self.assertEqual(result, self._oauth_part_response()) |
| 919 | + |
| 920 | + def test_upload_large_oauth_single_retry_then_propagate(self): |
| 921 | + """Should retry once then propagate when the token stays rejected""" |
| 922 | + cloudinary.config(oauth_token="expired-token") |
| 923 | + |
| 924 | + refresh_calls = {"n": 0} |
| 925 | + |
| 926 | + def refresh(rejected): |
| 927 | + refresh_calls["n"] += 1 |
| 928 | + cloudinary.config(oauth_token="still-bad-token") |
| 929 | + |
| 930 | + cloudinary.config().oauth_token_refresh_callback = refresh |
| 931 | + |
| 932 | + def side_effect(file, http_headers=None, **options): |
| 933 | + raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401") |
| 934 | + |
| 935 | + with self.assertRaises(exceptions.AuthorizationRequired): |
| 936 | + self._run_upload_large_with_oauth(side_effect) |
| 937 | + |
| 938 | + self.assertEqual(refresh_calls["n"], 1) |
| 939 | + |
| 940 | + def test_upload_large_oauth_no_hook_propagates(self): |
| 941 | + """Should propagate the first 401 with no retry when no callback is registered""" |
| 942 | + cloudinary.config(oauth_token="expired-token") |
| 943 | + |
| 944 | + calls = {"n": 0} |
| 945 | + |
| 946 | + def side_effect(file, http_headers=None, **options): |
| 947 | + calls["n"] += 1 |
| 948 | + raise exceptions.AuthorizationRequired("Server returned unexpected status code - 401") |
| 949 | + |
| 950 | + with self.assertRaises(exceptions.AuthorizationRequired): |
| 951 | + self._run_upload_large_with_oauth(side_effect) |
| 952 | + |
| 953 | + self.assertEqual(calls["n"], 1) |
| 954 | + |
| 955 | + def test_upload_large_oauth_non_auth_error_not_retried(self): |
| 956 | + """Should not retry non-auth errors even when a callback is registered""" |
| 957 | + cloudinary.config(oauth_token="some-token") |
| 958 | + |
| 959 | + refresh_calls = {"n": 0} |
| 960 | + |
| 961 | + def refresh(rejected): |
| 962 | + refresh_calls["n"] += 1 |
| 963 | + |
| 964 | + cloudinary.config().oauth_token_refresh_callback = refresh |
| 965 | + |
| 966 | + for error in (exceptions.BadRequest("bad request"), |
| 967 | + exceptions.Error("Socket error: some transient failure")): |
| 968 | + refresh_calls["n"] = 0 |
| 969 | + calls = {"n": 0} |
| 970 | + |
| 971 | + def side_effect(file, http_headers=None, _err=error, **options): |
| 972 | + calls["n"] += 1 |
| 973 | + raise _err |
| 974 | + |
| 975 | + with self.assertRaises(type(error)): |
| 976 | + self._run_upload_large_with_oauth(side_effect) |
| 977 | + |
| 978 | + self.assertEqual(calls["n"], 1) |
| 979 | + self.assertEqual(refresh_calls["n"], 0) |
| 980 | + |
| 981 | + @patch(URLLIB3_REQUEST) |
| 982 | + def test_upload_large_oauth_rejected_token_is_the_one_sent(self, request_mock): |
| 983 | + """Should pass the token actually sent (pinned) to the callback when it rotates per read""" |
| 984 | + token_counter = {"n": 0} |
| 985 | + |
| 986 | + class RotatingConfig(cloudinary.Config): |
| 987 | + @property |
| 988 | + def oauth_token(self): |
| 989 | + token_counter["n"] += 1 |
| 990 | + return "token-{}".format(token_counter["n"]) |
| 991 | + |
| 992 | + @oauth_token.setter |
| 993 | + def oauth_token(self, value): |
| 994 | + pass |
| 995 | + |
| 996 | + rotating = RotatingConfig() |
| 997 | + rejected_seen = {} |
| 998 | + |
| 999 | + def refresh(rejected): |
| 1000 | + rejected_seen["token"] = rejected |
| 1001 | + request_mock.return_value = uploader_response_mock() |
| 1002 | + |
| 1003 | + rotating.oauth_token_refresh_callback = refresh |
| 1004 | + |
| 1005 | + responses = {"first": True} |
| 1006 | + |
| 1007 | + def request_side_effect(*args, **kwargs): |
| 1008 | + if responses["first"]: |
| 1009 | + responses["first"] = False |
| 1010 | + rejected_seen["sent"] = kwargs["headers"].get("authorization", "").replace("Bearer ", "") |
| 1011 | + return http_response_mock('{"error":{"message":"401"}}', status=401) |
| 1012 | + return uploader_response_mock() |
| 1013 | + |
| 1014 | + request_mock.side_effect = request_side_effect |
| 1015 | + |
| 1016 | + with patch("cloudinary.config", return_value=rotating): |
| 1017 | + with io.BytesIO() as temp_file: |
| 1018 | + populate_large_file(temp_file, self._OAUTH_CHUNK_SIZE) |
| 1019 | + uploader.upload_large(temp_file, chunk_size=self._OAUTH_CHUNK_SIZE, |
| 1020 | + tags=[UNIQUE_TAG], resource_type="image") |
| 1021 | + |
| 1022 | + self.assertEqual(rejected_seen["token"], rejected_seen["sent"]) |
| 1023 | + |
840 | 1024 | @patch(URLLIB3_REQUEST) |
841 | 1025 | @unittest.skipUnless(cloudinary.config().api_secret, "requires api_key/api_secret") |
842 | 1026 | def test_upload_preset(self, mocker): |
|
0 commit comments