Repository: getmoto/moto

Issue:
head_object response should always contain ContentType
When using boto3 for real, the return value of a `head_object` call *always* contains a `ContentType` key, at least as far as I can tell. Even if you upload a completely empty file without specifying a content type and then call `head_object`, its `ContentType` will have been populated with `binary/octet-stream`.

However, in `moto`, this key is not guaranteed to be present. For example, if you run:

```
import moto
import boto3
from io import BytesIO

with moto.mock_s3():
    s3_resource = boto3.resource("s3", region_name="us-east-1")
    s3_resource.create_bucket(Bucket="testbucket")
    s3_resource.Bucket("testbucket").upload_fileobj(
        BytesIO(b'foobar'), Key="testobject"
    )
    s3_client = boto3.client("s3", region_name="us-east-1")
    print(
        "metadata:",
        s3_client.head_object(
            Bucket="testbucket",
            Key="testobject",
        ),
    )
```

then the dict that gets printed will not contain a `ContentType` key at all.

This leads to `KeyError`s when testing code that assumes the `ContentType` key will be present and reads it unconditionally (which is a safe assumption with the real boto3 implementation!).

It'd be helpful to ensure this value is always set, even if just to a hard-coded default value like `binary/octet-stream`.


Discussion:
Hi @ExplodingCabbage, welcome to Moto, and thanks for raising this! Good point - will mark it as an enhancement to always return this attribute.

Resolving patch:
diff --git a/moto/s3/models.py b/moto/s3/models.py
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -25,6 +25,7 @@
 )
 from moto.cloudwatch.models import MetricDatum
 from moto.utilities.tagging_service import TaggingService
+from moto.utilities.utils import LowercaseDict
 from moto.s3.exceptions import (
     AccessDeniedByLock,
     BucketAlreadyExists,
@@ -113,7 +114,7 @@ def __init__(
         self.acl = get_canned_acl("private")
         self.website_redirect_location = None
         self._storage_class = storage if storage else "STANDARD"
-        self._metadata = {}
+        self._metadata = LowercaseDict()
         self._expiry = None
         self._etag = etag
         self._version_id = version_id
@@ -136,6 +137,9 @@ def __init__(
         self.lock_legal_status = lock_legal_status
         self.lock_until = lock_until
 
+        # Default metadata values
+        self._metadata["Content-Type"] = "binary/octet-stream"
+
     @property
     def version_id(self):
         return self._version_id
diff --git a/moto/utilities/utils.py b/moto/utilities/utils.py
--- a/moto/utilities/utils.py
+++ b/moto/utilities/utils.py
@@ -4,6 +4,9 @@
 import pkgutil
 
 
+from collections.abc import MutableMapping
+
+
 def str2bool(v):
     if v in ("yes", True, "true", "True", "TRUE", "t", "1"):
         return True
@@ -52,3 +55,32 @@ def filter_resources(resources, filters, attr_pairs):
                     result.remove(resource)
                     break
     return result
+
+
+class LowercaseDict(MutableMapping):
+    """A dictionary that lowercases all keys"""
+
+    def __init__(self, *args, **kwargs):
+        self.store = dict()
+        self.update(dict(*args, **kwargs))  # use the free update to set keys
+
+    def __getitem__(self, key):
+        return self.store[self._keytransform(key)]
+
+    def __setitem__(self, key, value):
+        self.store[self._keytransform(key)] = value
+
+    def __delitem__(self, key):
+        del self.store[self._keytransform(key)]
+
+    def __iter__(self):
+        return iter(self.store)
+
+    def __len__(self):
+        return len(self.store)
+
+    def __repr__(self):
+        return str(self.store)
+
+    def _keytransform(self, key):
+        return key.lower()


Test patch:
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -6468,3 +6468,21 @@ def test_delete_objects_with_empty_keyname():
 
     client.delete_object(Bucket=bucket_name, Key=key_name)
     client.list_objects(Bucket=bucket_name).shouldnt.have.key("Contents")
+
+
+@mock_s3
+def test_head_object_should_return_default_content_type():
+    s3 = boto3.resource("s3", region_name="us-east-1")
+    s3.create_bucket(Bucket="testbucket")
+    s3.Bucket("testbucket").upload_fileobj(BytesIO(b"foobar"), Key="testobject")
+    s3_client = boto3.client("s3", region_name="us-east-1")
+    resp = s3_client.head_object(Bucket="testbucket", Key="testobject")
+
+    resp["ContentType"].should.equal("binary/octet-stream")
+    resp["ResponseMetadata"]["HTTPHeaders"]["content-type"].should.equal(
+        "binary/octet-stream"
+    )
+
+    s3.Object("testbucket", "testobject").content_type.should.equal(
+        "binary/octet-stream"
+    )