Repository: getmoto/moto

Issue:
HeadObject validation does not honor all validation parameters
Hi! This issue appears related to https://github.com/spulec/moto/issues/2263

**Observed Behavior**
I'm using `unittest` to write a test that will ensure validation on HeadObject calls work as expected. My `setUp` function includes the following code:
```python3.7
TEST_BUCKET = "fake_bucket"
TEST_KEY = "fake.csv"

@mock_s3
class TestVerifyS3Object(unittest.TestCase):
    def setUp(self):
        # Some other code ...
        self.s3_client = boto3.client("s3", region_name="us-east-1")
        self.s3_client.create_bucket(Bucket=TEST_BUCKET)
        self.s3_client.upload_file(Filename=tmp_file, Bucket=TEST_BUCKET, Key=TEST_KEY)
```

My unit tests have attempted both of the following:
```python3.7
        with self.assertRaises(botocore.exceptions.ClientError):
            self.s3_client.head_object(Bucket=TEST_BUCKET, Key=TEST_KEY, IfMatch="foo")
```
and
```python3.7
        with self.assertRaises(botocore.exceptions.ClientError):
            self.s3_client.head_object(
                Bucket=TEST_BUCKET, Key=TEST_KEY, IfUnmodifiedSince=datetime.datetime.utcnow())
```
Both result in `AssertionError: ClientError not raised`
I am invoking the tests locally using `nose==1.3.7`, and using `moto==1.3.14` which I installed using `pip`.

**Expected Behavior**
When I run the function I'm testing in a real environment using `boto==2.49.0`, `boto3==1.11.8`, and `botocore==1.14.9`, this code raises a `botocore.exceptions.ClientError`:
```python3.7
In [17]: try:
    ...:     client.head_object(
    ...:         Bucket=TEST_BUCKET, Key=TEST_KEY, IfMatch="foo",
    ...:     )
    ...: except botocore.exceptions.ClientError as e:
    ...:     print(e)
    ...:
An error occurred (412) when calling the HeadObject operation: Precondition Failed
```
This error response also happens when I execute this code in a lambda.

**Suspected Reason**
I did a little digging and have one idea as to why. I am admittedly not too familiar with this codebase but I hope this is helpful: 

The [`_key_response_head`](https://github.com/spulec/moto/blob/master/moto/s3/responses.py#L1114) method only looks at the `If-Modified-Since` header in the request, when actually there are [several validation headers](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#S3.Client.head_object) that may be passed to the `head_object` operation.

**If so...**
If this is the case, then I imagine something similar to https://github.com/spulec/moto/pull/2276/files would be necessary. If it is that simple, I'd be willing to take this on.

Thanks for reading!


Discussion:
Thanks for opening! I believe your diagnosis is correct and that we don't validate on any of the other headers.

If you get a chance, a PR would be great!

Resolving patch:
diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py
--- a/moto/s3/exceptions.py
+++ b/moto/s3/exceptions.py
@@ -1,6 +1,6 @@
 from __future__ import unicode_literals
-from moto.core.exceptions import RESTError
 
+from moto.core.exceptions import RESTError
 
 ERROR_WITH_BUCKET_NAME = """{% extends 'single_error' %}
 {% block extra %}<BucketName>{{ bucket }}</BucketName>{% endblock %}
@@ -10,6 +10,10 @@
 {% block extra %}<KeyName>{{ key_name }}</KeyName>{% endblock %}
 """
 
+ERROR_WITH_CONDITION_NAME = """{% extends 'single_error' %}
+{% block extra %}<Condition>{{ condition }}</Condition>{% endblock %}
+"""
+
 
 class S3ClientError(RESTError):
     def __init__(self, *args, **kwargs):
@@ -386,3 +390,17 @@ def __init__(self):
         super(NoSuchUpload, self).__init__(
             "NoSuchUpload", "The specified multipart upload does not exist."
         )
+
+
+class PreconditionFailed(S3ClientError):
+    code = 412
+
+    def __init__(self, failed_condition, **kwargs):
+        kwargs.setdefault("template", "condition_error")
+        self.templates["condition_error"] = ERROR_WITH_CONDITION_NAME
+        super(PreconditionFailed, self).__init__(
+            "PreconditionFailed",
+            "At least one of the pre-conditions you specified did not hold",
+            condition=failed_condition,
+            **kwargs
+        )
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -36,6 +36,7 @@
     InvalidNotificationEvent,
     ObjectNotInActiveTierError,
     NoSystemTags,
+    PreconditionFailed,
 )
 from .models import (
     s3_backend,
@@ -1149,13 +1150,28 @@ def _key_response_get(self, bucket_name, query, key_name, headers):
             )
         version_id = query.get("versionId", [None])[0]
         if_modified_since = headers.get("If-Modified-Since", None)
+        if_match = headers.get("If-Match", None)
+        if_none_match = headers.get("If-None-Match", None)
+        if_unmodified_since = headers.get("If-Unmodified-Since", None)
+
         key = self.backend.get_object(bucket_name, key_name, version_id=version_id)
         if key is None:
             raise MissingKey(key_name)
+
+        if if_unmodified_since:
+            if_unmodified_since = str_to_rfc_1123_datetime(if_unmodified_since)
+            if key.last_modified > if_unmodified_since:
+                raise PreconditionFailed("If-Unmodified-Since")
+        if if_match and key.etag != if_match:
+            raise PreconditionFailed("If-Match")
+
         if if_modified_since:
             if_modified_since = str_to_rfc_1123_datetime(if_modified_since)
-        if if_modified_since and key.last_modified < if_modified_since:
+            if key.last_modified < if_modified_since:
+                return 304, response_headers, "Not Modified"
+        if if_none_match and key.etag == if_none_match:
             return 304, response_headers, "Not Modified"
+
         if "acl" in query:
             template = self.response_template(S3_OBJECT_ACL_RESPONSE)
             return 200, response_headers, template.render(obj=key)
@@ -1319,8 +1335,9 @@ def _key_response_head(self, bucket_name, query, key_name, headers):
             part_number = int(part_number)
 
         if_modified_since = headers.get("If-Modified-Since", None)
-        if if_modified_since:
-            if_modified_since = str_to_rfc_1123_datetime(if_modified_since)
+        if_match = headers.get("If-Match", None)
+        if_none_match = headers.get("If-None-Match", None)
+        if_unmodified_since = headers.get("If-Unmodified-Since", None)
 
         key = self.backend.get_object(
             bucket_name, key_name, version_id=version_id, part_number=part_number
@@ -1329,10 +1346,21 @@ def _key_response_head(self, bucket_name, query, key_name, headers):
             response_headers.update(key.metadata)
             response_headers.update(key.response_dict)
 
-            if if_modified_since and key.last_modified < if_modified_since:
+            if if_unmodified_since:
+                if_unmodified_since = str_to_rfc_1123_datetime(if_unmodified_since)
+                if key.last_modified > if_unmodified_since:
+                    return 412, response_headers, ""
+            if if_match and key.etag != if_match:
+                return 412, response_headers, ""
+
+            if if_modified_since:
+                if_modified_since = str_to_rfc_1123_datetime(if_modified_since)
+                if key.last_modified < if_modified_since:
+                    return 304, response_headers, "Not Modified"
+            if if_none_match and key.etag == if_none_match:
                 return 304, response_headers, "Not Modified"
-            else:
-                return 200, response_headers, ""
+
+            return 200, response_headers, ""
         else:
             return 404, response_headers, ""
 


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
@@ -2335,6 +2335,64 @@ def test_boto3_get_object_if_modified_since():
     e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"})
 
 
+@mock_s3
+def test_boto3_get_object_if_unmodified_since():
+    s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    bucket_name = "blah"
+    s3.create_bucket(Bucket=bucket_name)
+
+    key = "hello.txt"
+
+    s3.put_object(Bucket=bucket_name, Key=key, Body="test")
+
+    with assert_raises(botocore.exceptions.ClientError) as err:
+        s3.get_object(
+            Bucket=bucket_name,
+            Key=key,
+            IfUnmodifiedSince=datetime.datetime.utcnow() - datetime.timedelta(hours=1),
+        )
+    e = err.exception
+    e.response["Error"]["Code"].should.equal("PreconditionFailed")
+    e.response["Error"]["Condition"].should.equal("If-Unmodified-Since")
+
+
+@mock_s3
+def test_boto3_get_object_if_match():
+    s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    bucket_name = "blah"
+    s3.create_bucket(Bucket=bucket_name)
+
+    key = "hello.txt"
+
+    s3.put_object(Bucket=bucket_name, Key=key, Body="test")
+
+    with assert_raises(botocore.exceptions.ClientError) as err:
+        s3.get_object(
+            Bucket=bucket_name, Key=key, IfMatch='"hello"',
+        )
+    e = err.exception
+    e.response["Error"]["Code"].should.equal("PreconditionFailed")
+    e.response["Error"]["Condition"].should.equal("If-Match")
+
+
+@mock_s3
+def test_boto3_get_object_if_none_match():
+    s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    bucket_name = "blah"
+    s3.create_bucket(Bucket=bucket_name)
+
+    key = "hello.txt"
+
+    etag = s3.put_object(Bucket=bucket_name, Key=key, Body="test")["ETag"]
+
+    with assert_raises(botocore.exceptions.ClientError) as err:
+        s3.get_object(
+            Bucket=bucket_name, Key=key, IfNoneMatch=etag,
+        )
+    e = err.exception
+    e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"})
+
+
 @mock_s3
 def test_boto3_head_object_if_modified_since():
     s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
@@ -2355,6 +2413,62 @@ def test_boto3_head_object_if_modified_since():
     e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"})
 
 
+@mock_s3
+def test_boto3_head_object_if_unmodified_since():
+    s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    bucket_name = "blah"
+    s3.create_bucket(Bucket=bucket_name)
+
+    key = "hello.txt"
+
+    s3.put_object(Bucket=bucket_name, Key=key, Body="test")
+
+    with assert_raises(botocore.exceptions.ClientError) as err:
+        s3.head_object(
+            Bucket=bucket_name,
+            Key=key,
+            IfUnmodifiedSince=datetime.datetime.utcnow() - datetime.timedelta(hours=1),
+        )
+    e = err.exception
+    e.response["Error"].should.equal({"Code": "412", "Message": "Precondition Failed"})
+
+
+@mock_s3
+def test_boto3_head_object_if_match():
+    s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    bucket_name = "blah"
+    s3.create_bucket(Bucket=bucket_name)
+
+    key = "hello.txt"
+
+    s3.put_object(Bucket=bucket_name, Key=key, Body="test")
+
+    with assert_raises(botocore.exceptions.ClientError) as err:
+        s3.head_object(
+            Bucket=bucket_name, Key=key, IfMatch='"hello"',
+        )
+    e = err.exception
+    e.response["Error"].should.equal({"Code": "412", "Message": "Precondition Failed"})
+
+
+@mock_s3
+def test_boto3_head_object_if_none_match():
+    s3 = boto3.client("s3", region_name=DEFAULT_REGION_NAME)
+    bucket_name = "blah"
+    s3.create_bucket(Bucket=bucket_name)
+
+    key = "hello.txt"
+
+    etag = s3.put_object(Bucket=bucket_name, Key=key, Body="test")["ETag"]
+
+    with assert_raises(botocore.exceptions.ClientError) as err:
+        s3.head_object(
+            Bucket=bucket_name, Key=key, IfNoneMatch=etag,
+        )
+    e = err.exception
+    e.response["Error"].should.equal({"Code": "304", "Message": "Not Modified"})
+
+
 @mock_s3
 @reduced_min_part_size
 def test_boto3_multipart_etag():