Repository: getmoto/moto

Issue:
S3 ObjectVersion different behavior in moto
I'm using this code to recover deleted objects from AWS :

```
s3 = boto3.resource('s3')
bucket = s3.Bucket(bucket)
versions = bucket.object_versions

...


def is_delete_marker(version):
    try:
        # note head() is faster than get()
        version.head()
        return False
    except botocore.exceptions.ClientError as e:
        if 'x-amz-delete-marker' in e.response['ResponseMetadata']['HTTPHeaders']:
            return True
        # an older version of the key but not a DeleteMarker
        elif '404' == e.response['Error']['Code']:
            return False


def recover(versions, key, logger):
    for version in versions.filter(Prefix=key):
        if is_delete_marker(version):
            logger.info(f'version {version.key} {version.last_modified} recovered')
            version.delete()
```

I noticed that this code behaves differently when using moto, especially the `x-amz-delete-marker` isn't preset in `HTTPHeaders`. I didn't find a specific documentation for that but i think moto should behave the same.


Discussion:
Hi @bmaisonn, can you share a minimal reproducible example?

What version of Moto are you using?
I couldn't reproduce this based on the provided code snippet - or more specifically, I could not figure out why/when AWS returns this `x-amz-delete-marker`-header. Closing this - if you do have something reproducible I'd be happy to have another look.
Sorry for the late answer on this @bblommers if you run this code on a bucket with versioning enabled you'll get the following output
![image](https://github.com/getmoto/moto/assets/7989822/8d78ef18-7bbe-4806-9206-2109d9d1e0a0)

In this i just create an object on s3, delete an try to remove the delete marker from the versions when there's one.

```python
import moto
import io
import boto3
import botocore


BUCKET_NAME = 'XXX'
KEY = 'test_delete_marker.txt'


s3 = boto3.resource("s3", region_name="us-east-1")
s3_client = boto3.client('s3')

with io.BytesIO() as f:
    f.write(b'test')
    s3_client.put_object(Bucket=BUCKET_NAME, Key=KEY,Body=f)

s3_client.delete_object(Bucket=BUCKET_NAME, Key=KEY)


s3 = boto3.resource('s3')
bucket = s3.Bucket(BUCKET_NAME)
versions = bucket.object_versions

def is_delete_marker(version):
    try:
        # note head() is faster than get()
        version.head()
        return False
    except botocore.exceptions.ClientError as e:
        if 'x-amz-delete-marker' in e.response['ResponseMetadata']['HTTPHeaders']:
            return True
        # an older version of the key but not a DeleteMarker
        elif '404' == e.response['Error']['Code']:
            return False


def recover(versions, key):
    for version in versions.filter(Prefix=key):
        if is_delete_marker(version):
            print(f'version {version.key} {version.last_modified} recovered')
            version.delete()

recover(versions, KEY)
```

If i run the same with moto i don't see any output here even if i see some version listed for that key

```python
import moto
import io
import boto3
import botocore


#BUCKET_NAME = 'test'
BUCKET_NAME = 'XXX'
KEY = 'test_delete_marker.txt'


with moto.mock_s3():
    s3 = boto3.resource("s3", region_name="us-east-1")
    s3_client = boto3.client('s3')

    s3.create_bucket(Bucket=BUCKET_NAME)
    s3_client.put_bucket_versioning(Bucket=BUCKET_NAME, VersioningConfiguration={'Status': 'Enabled'})


    with io.BytesIO() as f:
        f.write(b'test')
        s3_client.put_object(Bucket=BUCKET_NAME, Key=KEY,Body=f)

    s3_client.delete_object(Bucket=BUCKET_NAME, Key=KEY)


    s3 = boto3.resource('s3')
    bucket = s3.Bucket(BUCKET_NAME)
    versions = bucket.object_versions

    def is_delete_marker(version):
        try:
            # note head() is faster than get()
            version.head()
            return False
        except botocore.exceptions.ClientError as e:
            if 'x-amz-delete-marker' in e.response['ResponseMetadata']['HTTPHeaders']:
                return True
            # an older version of the key but not a DeleteMarker
            elif '404' == e.response['Error']['Code']:
                return False


    def recover(versions, key):
        for version in versions.filter(Prefix=key):
            if is_delete_marker(version):
                print(f'version {version.key} {version.last_modified} recovered')
                version.delete()

    recover(versions, KEY)
```
Ah, gotcha. Not sure what I did wrong initially - but I can reproduce now. Looks like Moto returns a generic 404 when trying to call `head()` on a DeleteMarker, instead of a 405. 

Resolving patch:
diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py
--- a/moto/s3/exceptions.py
+++ b/moto/s3/exceptions.py
@@ -559,3 +559,10 @@ def __init__(self) -> None:
             "ObjectLockConfigurationNotFoundError",
             "Object Lock configuration does not exist for this bucket",
         )
+
+
+class MethodNotAllowed(S3ClientError):
+    code = 405
+
+    def __init__(self) -> None:
+        super().__init__("MethodNotAllowed", "Method Not Allowed")
diff --git a/moto/s3/models.py b/moto/s3/models.py
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -42,6 +42,7 @@
     MissingKey,
     InvalidNotificationDestination,
     MalformedXML,
+    MethodNotAllowed,
     InvalidStorageClass,
     InvalidTargetBucketForLogging,
     CrossLocationLoggingProhibitted,
@@ -2068,6 +2069,7 @@ def get_object(
         key_name: str,
         version_id: Optional[str] = None,
         part_number: Optional[str] = None,
+        return_delete_marker: bool = False,
     ) -> Optional[FakeKey]:
         bucket = self.get_bucket(bucket_name)
 
@@ -2090,6 +2092,8 @@ def get_object(
             key.advance()
             return key
         else:
+            if return_delete_marker and isinstance(key, FakeDeleteMarker):
+                return key  # type: ignore
             return None
 
     def head_object(
@@ -2099,7 +2103,12 @@ def head_object(
         version_id: Optional[str] = None,
         part_number: Optional[str] = None,
     ) -> Optional[FakeKey]:
-        return self.get_object(bucket_name, key_name, version_id, part_number)
+        obj = self.get_object(
+            bucket_name, key_name, version_id, part_number, return_delete_marker=True
+        )
+        if isinstance(obj, FakeDeleteMarker):
+            raise MethodNotAllowed
+        return obj
 
     def get_object_acl(self, key: FakeKey) -> Optional[FakeAcl]:
         return key.acl
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -34,6 +34,7 @@
     InvalidContentMD5,
     InvalidContinuationToken,
     S3ClientError,
+    MethodNotAllowed,
     MissingBucket,
     MissingKey,
     MissingVersion,
@@ -1761,9 +1762,18 @@ def _key_response_head(
         if_none_match = headers.get("If-None-Match", None)
         if_unmodified_since = headers.get("If-Unmodified-Since", None)
 
-        key = self.backend.head_object(
-            bucket_name, key_name, version_id=version_id, part_number=part_number
-        )
+        try:
+            key = self.backend.head_object(
+                bucket_name, key_name, version_id=version_id, part_number=part_number
+            )
+        except MethodNotAllowed:
+            headers = {
+                "x-amz-delete-marker": "true",
+                "x-amz-version-id": version_id,
+                "allow": "DELETE",
+                "content-type": "application/xml",
+            }
+            return 405, headers, "Method Not Allowed"
         if key:
             response_headers.update(key.metadata)
             response_headers.update(key.response_dict)


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
@@ -1684,6 +1684,9 @@ def test_delete_versioned_bucket():
 @s3_aws_verified
 def test_delete_versioned_bucket_returns_metadata(name=None):
     client = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    resource = boto3.resource("s3", region_name=DEFAULT_REGION_NAME)
+    bucket = resource.Bucket(name)
+    versions = bucket.object_versions
 
     client.put_bucket_versioning(
         Bucket=name, VersioningConfiguration={"Status": "Enabled"}
@@ -1696,12 +1699,30 @@ def test_delete_versioned_bucket_returns_metadata(name=None):
 
     # Delete the object
     del_file = client.delete_object(Bucket=name, Key="test1")
+    deleted_version_id = del_file["VersionId"]
     assert del_file["DeleteMarker"] is True
-    assert del_file["VersionId"] is not None
+    assert deleted_version_id is not None
 
     # We now have one DeleteMarker
     assert len(client.list_object_versions(Bucket=name)["DeleteMarkers"]) == 1
 
+    # list_object_versions returns the object itself, and a DeleteMarker
+    # object.head() returns a 'x-amz-delete-marker' header
+    # delete_marker.head() returns a 405
+    for version in versions.filter(Prefix="test1"):
+        if version.version_id == deleted_version_id:
+            with pytest.raises(ClientError) as exc:
+                version.head()
+            err = exc.value.response
+            assert err["Error"] == {"Code": "405", "Message": "Method Not Allowed"}
+            assert err["ResponseMetadata"]["HTTPStatusCode"] == 405
+            assert (
+                err["ResponseMetadata"]["HTTPHeaders"]["x-amz-delete-marker"] == "true"
+            )
+            assert err["ResponseMetadata"]["HTTPHeaders"]["allow"] == "DELETE"
+        else:
+            assert version.head()["ResponseMetadata"]["HTTPStatusCode"] == 200
+
     # Delete the same object gives a new version id
     del_mrk1 = client.delete_object(Bucket=name, Key="test1")
     assert del_mrk1["DeleteMarker"] is True
@@ -1717,6 +1738,20 @@ def test_delete_versioned_bucket_returns_metadata(name=None):
     assert del_mrk2["DeleteMarker"] is True
     assert del_mrk2["VersionId"] == del_mrk1["VersionId"]
 
+    for version in versions.filter(Prefix="test1"):
+        if version.version_id == deleted_version_id:
+            with pytest.raises(ClientError) as exc:
+                version.head()
+            err = exc.value.response
+            assert err["Error"] == {"Code": "405", "Message": "Method Not Allowed"}
+            assert err["ResponseMetadata"]["HTTPStatusCode"] == 405
+            assert (
+                err["ResponseMetadata"]["HTTPHeaders"]["x-amz-delete-marker"] == "true"
+            )
+            assert err["ResponseMetadata"]["HTTPHeaders"]["allow"] == "DELETE"
+        else:
+            assert version.head()["ResponseMetadata"]["HTTPStatusCode"] == 200
+
     # We now have only one DeleteMarker
     assert len(client.list_object_versions(Bucket=name)["DeleteMarkers"]) == 1
 
@@ -1732,6 +1767,13 @@ def test_delete_versioned_bucket_returns_metadata(name=None):
     assert len(client.list_object_versions(Bucket=name)["DeleteMarkers"]) == 1
     assert "Versions" not in client.list_object_versions(Bucket=name)
 
+    # Because we only have DeleteMarkers, we can not call `head()` on any of othem
+    for version in versions.filter(Prefix="test1"):
+        with pytest.raises(ClientError) as exc:
+            version.head()
+        err = exc.value.response
+        assert err["Error"] == {"Code": "405", "Message": "Method Not Allowed"}
+
     # Delete the last marker
     del_mrk4 = client.delete_object(
         Bucket=name, Key="test1", VersionId=del_mrk2["VersionId"]