|
43 | 43 |
|
44 | 44 | from minio import Minio |
45 | 45 | from minio.checksum import Algorithm |
46 | | -from minio.commonconfig import ENABLED, REPLACE, CopySource, SnowballObject |
| 46 | +from minio.commonconfig import ENABLED, REPLACE, ComposeSource, CopySource, SnowballObject |
47 | 47 | from minio.datatypes import PostPolicy |
48 | 48 | from minio.deleteobjects import DeleteObject |
49 | 49 | from minio.error import S3Error |
@@ -2001,6 +2001,284 @@ def test_presigned_post_policy(log_entry): |
2001 | 2001 | _client.remove_bucket(bucket_name=bucket_name) |
2002 | 2002 |
|
2003 | 2003 |
|
| 2004 | +def test_backward_compatibility_positional_args(log_entry): |
| 2005 | + """ |
| 2006 | + Critical test: Verify all main APIs accept positional arguments. |
| 2007 | + This was BROKEN in 6daf366 and must work in our fix. |
| 2008 | + """ |
| 2009 | + log_entry["name"] = "test_backward_compatibility_positional_args" |
| 2010 | + bucket_name = _gen_bucket_name() |
| 2011 | + |
| 2012 | + log_entry["args"] = { |
| 2013 | + "bucket_name": bucket_name, |
| 2014 | + } |
| 2015 | + |
| 2016 | + try: |
| 2017 | + _client.make_bucket(bucket_name) |
| 2018 | + |
| 2019 | + # Test 1: put_object with positional args (BROKEN in master) |
| 2020 | + data = io.BytesIO(b"test data for positional args") |
| 2021 | + _client.put_object(bucket_name, "test1", data, 29) # All positional |
| 2022 | + |
| 2023 | + # Test 2: fput_object with positional args |
| 2024 | + _client.fput_object(bucket_name, "test2", _test_file) # Positional |
| 2025 | + |
| 2026 | + # Test 3: get_object with positional args |
| 2027 | + _client.get_object(bucket_name, "test1") # Positional |
| 2028 | + |
| 2029 | + # Test 4: stat_object with positional args |
| 2030 | + _client.stat_object(bucket_name, "test1") # Positional |
| 2031 | + |
| 2032 | + # Test 5: copy_object with positional args |
| 2033 | + _client.copy_object(bucket_name, "test3", CopySource(bucket_name, "test1")) |
| 2034 | + |
| 2035 | + # Test 6: list_objects with positional args |
| 2036 | + list(_client.list_objects(bucket_name)) # Positional |
| 2037 | + |
| 2038 | + # Test 7: remove_object with positional args |
| 2039 | + _client.remove_object(bucket_name, "test1") # Positional |
| 2040 | + |
| 2041 | + finally: |
| 2042 | + # Cleanup |
| 2043 | + for obj in _client.list_objects(bucket_name): |
| 2044 | + _client.remove_object(bucket_name, obj.object_name) |
| 2045 | + _client.remove_bucket(bucket_name) |
| 2046 | + |
| 2047 | + |
| 2048 | +def test_metadata_parameter_backward_compatibility(log_entry): |
| 2049 | + """ |
| 2050 | + Test that old 'metadata' parameter still works for backward compatibility. |
| 2051 | + 6daf366 split metadata -> headers/user_metadata, but we keep both. |
| 2052 | + """ |
| 2053 | + log_entry["name"] = "test_metadata_parameter_backward_compatibility" |
| 2054 | + bucket_name = _gen_bucket_name() |
| 2055 | + |
| 2056 | + log_entry["args"] = { |
| 2057 | + "bucket_name": bucket_name, |
| 2058 | + } |
| 2059 | + |
| 2060 | + try: |
| 2061 | + _client.make_bucket(bucket_name) |
| 2062 | + |
| 2063 | + # OLD API: Using 'metadata' parameter (must still work!) |
| 2064 | + old_metadata = HTTPHeaderDict({"X-Amz-Meta-Old-Key": "old-value"}) |
| 2065 | + _client.fput_object( |
| 2066 | + bucket_name, |
| 2067 | + "test-old-metadata", |
| 2068 | + _test_file, |
| 2069 | + metadata=old_metadata # OLD parameter |
| 2070 | + ) |
| 2071 | + |
| 2072 | + # Verify it was stored (user metadata keys have x-amz-meta- prefix) |
| 2073 | + stat = _client.stat_object(bucket_name, "test-old-metadata") |
| 2074 | + # Find the key (case-insensitive, may have x-amz-meta- prefix) |
| 2075 | + found_key = None |
| 2076 | + for key in stat.metadata.keys(): |
| 2077 | + if key.lower().endswith("old-key"): |
| 2078 | + found_key = key |
| 2079 | + break |
| 2080 | + if not found_key: |
| 2081 | + raise Exception(f"Old metadata parameter didn't work. Keys: {list(stat.metadata.keys())}") |
| 2082 | + if stat.metadata[found_key] != "old-value": |
| 2083 | + raise Exception(f"Old metadata value mismatch: {stat.metadata[found_key]}") |
| 2084 | + |
| 2085 | + # NEW API: Using 'user_metadata' parameter (should also work) |
| 2086 | + new_metadata = HTTPHeaderDict({"X-Amz-Meta-New-Key": "new-value"}) |
| 2087 | + _client.fput_object( |
| 2088 | + bucket_name, |
| 2089 | + "test-new-metadata", |
| 2090 | + _test_file, |
| 2091 | + user_metadata=new_metadata # NEW parameter |
| 2092 | + ) |
| 2093 | + |
| 2094 | + stat2 = _client.stat_object(bucket_name, "test-new-metadata") |
| 2095 | + # Find the key (case-insensitive, may have x-amz-meta- prefix) |
| 2096 | + found_key2 = None |
| 2097 | + for key in stat2.metadata.keys(): |
| 2098 | + if key.lower().endswith("new-key"): |
| 2099 | + found_key2 = key |
| 2100 | + break |
| 2101 | + if not found_key2: |
| 2102 | + raise Exception(f"New user_metadata parameter didn't work. Keys: {list(stat2.metadata.keys())}") |
| 2103 | + if stat2.metadata[found_key2] != "new-value": |
| 2104 | + raise Exception(f"New metadata value mismatch: {stat2.metadata[found_key2]}") |
| 2105 | + |
| 2106 | + finally: |
| 2107 | + for obj in _client.list_objects(bucket_name): |
| 2108 | + _client.remove_object(bucket_name, obj.object_name) |
| 2109 | + _client.remove_bucket(bucket_name) |
| 2110 | + |
| 2111 | + |
| 2112 | +def test_real_world_usage_patterns(log_entry): |
| 2113 | + """ |
| 2114 | + Test common usage patterns from real codebases. |
| 2115 | + These are patterns that would BREAK in master but MUST work in our fix. |
| 2116 | + """ |
| 2117 | + log_entry["name"] = "test_real_world_usage_patterns" |
| 2118 | + bucket_name = _gen_bucket_name() |
| 2119 | + |
| 2120 | + log_entry["args"] = { |
| 2121 | + "bucket_name": bucket_name, |
| 2122 | + } |
| 2123 | + |
| 2124 | + try: |
| 2125 | + # Pattern 1: Minimal arguments (very common) |
| 2126 | + _client.make_bucket(bucket_name) |
| 2127 | + |
| 2128 | + # Pattern 2: Quick upload without keywords (BROKEN in master) |
| 2129 | + with open(_test_file, 'rb') as file_data: |
| 2130 | + _client.put_object( |
| 2131 | + bucket_name, |
| 2132 | + "test", |
| 2133 | + file_data, |
| 2134 | + os.path.getsize(_test_file) |
| 2135 | + ) |
| 2136 | + |
| 2137 | + # Pattern 3: Copy with minimal args (BROKEN in master) |
| 2138 | + _client.copy_object(bucket_name, "test-copy", CopySource(bucket_name, "test")) |
| 2139 | + |
| 2140 | + # Pattern 4: List with prefix positionally (BROKEN in master) |
| 2141 | + list(_client.list_objects(bucket_name, "test")) |
| 2142 | + |
| 2143 | + # Pattern 5: Mixed positional and keyword (should work) |
| 2144 | + _client.fput_object(bucket_name, "test2", _test_file, content_type="text/plain") |
| 2145 | + |
| 2146 | + # Pattern 6: Stat with version (if supported) |
| 2147 | + _client.stat_object(bucket_name, "test") |
| 2148 | + |
| 2149 | + # Pattern 7: Remove positionally (BROKEN in master) |
| 2150 | + _client.remove_object(bucket_name, "test") |
| 2151 | + |
| 2152 | + finally: |
| 2153 | + for obj in _client.list_objects(bucket_name): |
| 2154 | + _client.remove_object(bucket_name, obj.object_name) |
| 2155 | + _client.remove_bucket(bucket_name) |
| 2156 | + |
| 2157 | + |
| 2158 | +def test_edge_cases_still_work(log_entry): |
| 2159 | + """ |
| 2160 | + Test edge cases that might break with signature changes. |
| 2161 | + """ |
| 2162 | + log_entry["name"] = "test_edge_cases_still_work" |
| 2163 | + bucket_name = _gen_bucket_name() |
| 2164 | + |
| 2165 | + log_entry["args"] = { |
| 2166 | + "bucket_name": bucket_name, |
| 2167 | + } |
| 2168 | + |
| 2169 | + try: |
| 2170 | + _client.make_bucket(bucket_name) |
| 2171 | + |
| 2172 | + # Edge 1: Empty metadata (both old and new way) |
| 2173 | + _client.fput_object(bucket_name, "test1", _test_file, metadata=HTTPHeaderDict()) |
| 2174 | + _client.fput_object(bucket_name, "test2", _test_file, user_metadata=HTTPHeaderDict()) |
| 2175 | + |
| 2176 | + # Edge 2: None metadata (explicit None) |
| 2177 | + _client.fput_object(bucket_name, "test3", _test_file, metadata=None) |
| 2178 | + |
| 2179 | + # Edge 3: Large metadata |
| 2180 | + large_meta = HTTPHeaderDict({f"X-Amz-Meta-Key{i}": f"value{i}" for i in range(20)}) |
| 2181 | + _client.fput_object(bucket_name, "test4", _test_file, metadata=large_meta) |
| 2182 | + |
| 2183 | + # Edge 4: Special characters in metadata |
| 2184 | + special_meta = HTTPHeaderDict({"X-Amz-Meta-Special-Key": "value with spaces & symbols!"}) |
| 2185 | + _client.fput_object(bucket_name, "test5", _test_file, metadata=special_meta) |
| 2186 | + |
| 2187 | + # Edge 5: Zero-byte file |
| 2188 | + zero_file = tempfile.NamedTemporaryFile(delete=False) |
| 2189 | + zero_file.close() |
| 2190 | + try: |
| 2191 | + _client.fput_object(bucket_name, "test6", zero_file.name) |
| 2192 | + finally: |
| 2193 | + os.unlink(zero_file.name) |
| 2194 | + |
| 2195 | + finally: |
| 2196 | + for obj in _client.list_objects(bucket_name): |
| 2197 | + _client.remove_object(bucket_name, obj.object_name) |
| 2198 | + _client.remove_bucket(bucket_name) |
| 2199 | + |
| 2200 | + |
| 2201 | +def test_no_regression_from_6daf366(log_entry): |
| 2202 | + """ |
| 2203 | + Specific regression tests for issues introduced in 6daf366. |
| 2204 | + """ |
| 2205 | + log_entry["name"] = "test_no_regression_from_6daf366" |
| 2206 | + bucket_name = _gen_bucket_name() |
| 2207 | + |
| 2208 | + log_entry["args"] = { |
| 2209 | + "bucket_name": bucket_name, |
| 2210 | + } |
| 2211 | + |
| 2212 | + # Create 6MB files for compose_object (minimum 5MB per source required) |
| 2213 | + part1_file = None |
| 2214 | + part2_file = None |
| 2215 | + try: |
| 2216 | + _client.make_bucket(bucket_name) |
| 2217 | + |
| 2218 | + # Create temporary 6MB files for compose_object |
| 2219 | + part1_file = tempfile.NamedTemporaryFile(delete=False) |
| 2220 | + part1_file.write(b"a" * (6 * 1024 * 1024)) # 6MB |
| 2221 | + part1_file.close() |
| 2222 | + |
| 2223 | + part2_file = tempfile.NamedTemporaryFile(delete=False) |
| 2224 | + part2_file.write(b"b" * (6 * 1024 * 1024)) # 6MB |
| 2225 | + part2_file.close() |
| 2226 | + |
| 2227 | + # Regression 1: compose_object must accept metadata parameter |
| 2228 | + _client.fput_object(bucket_name, "part1", part1_file.name) |
| 2229 | + _client.fput_object(bucket_name, "part2", part2_file.name) |
| 2230 | + |
| 2231 | + sources = [ |
| 2232 | + ComposeSource(bucket_name, "part1"), |
| 2233 | + ComposeSource(bucket_name, "part2"), |
| 2234 | + ] |
| 2235 | + |
| 2236 | + # This FAILED in 6daf366 if using old 'metadata' parameter |
| 2237 | + metadata = HTTPHeaderDict({"X-Amz-Meta-Test": "value"}) |
| 2238 | + _client.compose_object( |
| 2239 | + bucket_name, |
| 2240 | + "composed", |
| 2241 | + sources, |
| 2242 | + metadata=metadata # OLD parameter must work |
| 2243 | + ) |
| 2244 | + |
| 2245 | + stat = _client.stat_object(bucket_name, "composed") |
| 2246 | + # Find the key (case-insensitive, may have x-amz-meta- prefix) |
| 2247 | + found_test_key = None |
| 2248 | + for key in stat.metadata.keys(): |
| 2249 | + if key.lower().endswith("test"): |
| 2250 | + found_test_key = key |
| 2251 | + break |
| 2252 | + if not found_test_key: |
| 2253 | + raise Exception(f"compose_object metadata parameter didn't work. Keys: {list(stat.metadata.keys())}") |
| 2254 | + if stat.metadata[found_test_key] != "value": |
| 2255 | + raise Exception(f"compose_object metadata value mismatch: {stat.metadata[found_test_key]}") |
| 2256 | + |
| 2257 | + # Regression 2: copy_object with positional source |
| 2258 | + _client.copy_object( |
| 2259 | + bucket_name, |
| 2260 | + "copied", |
| 2261 | + CopySource(bucket_name, "part1") # Positional source |
| 2262 | + ) |
| 2263 | + |
| 2264 | + finally: |
| 2265 | + # Clean up temporary files |
| 2266 | + if part1_file: |
| 2267 | + try: |
| 2268 | + os.unlink(part1_file.name) |
| 2269 | + except: |
| 2270 | + pass |
| 2271 | + if part2_file: |
| 2272 | + try: |
| 2273 | + os.unlink(part2_file.name) |
| 2274 | + except: |
| 2275 | + pass |
| 2276 | + # Clean up bucket |
| 2277 | + for obj in _client.list_objects(bucket_name): |
| 2278 | + _client.remove_object(bucket_name, obj.object_name) |
| 2279 | + _client.remove_bucket(bucket_name) |
| 2280 | + |
| 2281 | + |
2004 | 2282 | def test_thread_safe(log_entry): |
2005 | 2283 | """Test thread safety.""" |
2006 | 2284 | bucket_name = _gen_bucket_name() |
@@ -2522,6 +2800,11 @@ def main(): |
2522 | 2800 | test_presigned_put_object_default_expiry: None, |
2523 | 2801 | test_presigned_put_object_expiry: None, |
2524 | 2802 | test_presigned_post_policy: None, |
| 2803 | + test_backward_compatibility_positional_args: None, |
| 2804 | + test_metadata_parameter_backward_compatibility: None, |
| 2805 | + test_real_world_usage_patterns: None, |
| 2806 | + test_edge_cases_still_work: None, |
| 2807 | + test_no_regression_from_6daf366: None, |
2525 | 2808 | test_thread_safe: None, |
2526 | 2809 | test_set_get_bucket_versioning: None, |
2527 | 2810 | test_get_bucket_policy: None, |
|
0 commit comments