Repository: getmoto/moto

Issue:
S3 MultiUpload: A non-positive part number gives a stack trace
Performing a multi-part upload using a part number which is not positive will result in a large traceback in the moto server, rather than just returning an error to the client.

## Traceback

```
Error on request:
Traceback (most recent call last):
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/Werkzeug-1.0.1-py3.9.egg/werkzeug/serving.py", line 323, in run_wsgi
    execute(self.server.app)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/Werkzeug-1.0.1-py3.9.egg/werkzeug/serving.py", line 312, in execute
    application_iter = app(environ, start_response)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/moto-1.3.16.dev0-py3.9.egg/moto/server.py", line 173, in __call__
    return backend_app(environ, start_response)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 2464, in __call__
    return self.wsgi_app(environ, start_response)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 2450, in wsgi_app
    response = self.handle_exception(e)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask_cors/extension.py", line 165, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 1867, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask_cors/extension.py", line 165, in wrapped_function
    return cors_after_request(app.make_response(f(*args, **kwargs)))
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/moto-1.3.16.dev0-py3.9.egg/moto/core/utils.py", line 151, in __call__
    result = self.callback(request, request.url, {})
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/moto-1.3.16.dev0-py3.9.egg/moto/s3/responses.py", line 975, in key_or_control_response
    response = self._key_response(request, full_url, headers)
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/moto-1.3.16.dev0-py3.9.egg/moto/s3/responses.py", line 1136, in _key_response
    return self._key_response_put(
  File "/home/ivanbakel/.local/share/virtualenvs/moto-oItiPM7T/lib/python3.9/site-packages/moto-1.3.16.dev0-py3.9.egg/moto/s3/responses.py", line 1260, in _key_response_put
    response_headers.update(key.response_dict)
AttributeError: 'NoneType' object has no attribute 'response_dict'
```
This happens [here](https://github.com/spulec/moto/blob/9784e1b4872df6b49bb08e790a3512c2895c2bf0/moto/s3/responses.py#L1260), and is related to [this empty return](https://github.com/spulec/moto/blob/9784e1b4872df6b49bb08e790a3512c2895c2bf0/moto/s3/models.py#L318)

## Steps to reproduce

Attempt a multi-part upload with a part number of 0. `moto_server` will crash with the above traceback.

## Moto version

Git-tracked to `master`, installed to a local venv.



Discussion:
Thanks for raising this @ivanbakel - marking it as an enhancement.

Resolving patch:
diff --git a/moto/s3/models.py b/moto/s3/models.py
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -333,7 +333,7 @@ def complete(self, body):
 
     def set_part(self, part_id, value):
         if part_id < 1:
-            return
+            raise NoSuchUpload(upload_id=part_id)
 
         key = FakeKey(part_id, value)
         self.parts[part_id] = key


Test patch:
diff --git a/tests/test_s3/test_s3_multipart.py b/tests/test_s3/test_s3_multipart.py
--- a/tests/test_s3/test_s3_multipart.py
+++ b/tests/test_s3/test_s3_multipart.py
@@ -4,6 +4,8 @@
 import pytest
 import sure  # noqa
 
+from .test_s3 import DEFAULT_REGION_NAME
+
 
 @mock_s3
 def test_multipart_should_throw_nosuchupload_if_there_are_no_parts():
@@ -22,3 +24,29 @@ def test_multipart_should_throw_nosuchupload_if_there_are_no_parts():
         "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed."
     )
     err["UploadId"].should.equal(multipart_upload.id)
+
+
+@mock_s3
+def test_boto3_multipart_part_size():
+    bucket_name = "mputest-3593"
+    s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    s3.create_bucket(Bucket=bucket_name)
+
+    mpu = s3.create_multipart_upload(Bucket=bucket_name, Key="the-key")
+    mpu_id = mpu["UploadId"]
+
+    body = b"111"
+    with pytest.raises(ClientError) as ex:
+        s3.upload_part(
+            Bucket=bucket_name,
+            Key="the-key",
+            PartNumber=-1,
+            UploadId=mpu_id,
+            Body=body,
+            ContentLength=len(body),
+        )
+    err = ex.value.response["Error"]
+    err["Code"].should.equal("NoSuchUpload")
+    err["Message"].should.equal(
+        "The specified upload does not exist. The upload ID may be invalid, or the upload may have been aborted or completed."
+    )