|
| 1 | +import gzip |
| 2 | +import random |
| 3 | +import struct |
| 4 | +from io import BytesIO |
| 5 | + |
| 6 | +import pytest |
| 7 | + |
| 8 | +from django.http import FileResponse, HttpResponse, StreamingHttpResponse |
| 9 | +from django.test import AsyncRequestFactory |
| 10 | + |
| 11 | +from django_async_extensions.middleware.gzip import AsyncGZipMiddleware |
| 12 | +from django_async_extensions.middleware.http import AsyncConditionalGetMiddleware |
| 13 | + |
| 14 | +int2byte = struct.Struct(">B").pack |
| 15 | + |
| 16 | + |
| 17 | +class TestGZipMiddleware: |
| 18 | + """ |
| 19 | + Tests the GZipMiddleware. |
| 20 | + """ |
| 21 | + |
| 22 | + short_string = b"This string is too short to be worth compressing." |
| 23 | + compressible_string = b"a" * 500 |
| 24 | + incompressible_string = b"".join( |
| 25 | + int2byte(random.randint(0, 255)) for _ in range(500) # noqa: S311 |
| 26 | + ) |
| 27 | + sequence = [b"a" * 500, b"b" * 200, b"a" * 300] |
| 28 | + sequence_unicode = ["a" * 500, "é" * 200, "a" * 300] |
| 29 | + request_factory = AsyncRequestFactory() |
| 30 | + |
| 31 | + @pytest.fixture(autouse=True) |
| 32 | + def setup(self): |
| 33 | + self.req = self.request_factory.get("/") |
| 34 | + self.req.META["HTTP_ACCEPT_ENCODING"] = "gzip, deflate" |
| 35 | + self.req.META["HTTP_USER_AGENT"] = ( |
| 36 | + "Mozilla/5.0 (Windows NT 5.1; rv:9.0.1) Gecko/20100101 Firefox/9.0.1" |
| 37 | + ) |
| 38 | + self.resp = HttpResponse() |
| 39 | + self.resp.status_code = 200 |
| 40 | + self.resp.content = self.compressible_string |
| 41 | + self.resp["Content-Type"] = "text/html; charset=UTF-8" |
| 42 | + |
| 43 | + async def get_response(self, request): |
| 44 | + return self.resp |
| 45 | + |
| 46 | + @staticmethod |
| 47 | + def decompress(gzipped_string): |
| 48 | + with gzip.GzipFile(mode="rb", fileobj=BytesIO(gzipped_string)) as f: |
| 49 | + return f.read() |
| 50 | + |
| 51 | + @staticmethod |
| 52 | + def get_mtime(gzipped_string): |
| 53 | + with gzip.GzipFile(mode="rb", fileobj=BytesIO(gzipped_string)) as f: |
| 54 | + f.read() # must read the data before accessing the header |
| 55 | + return f.mtime |
| 56 | + |
| 57 | + async def test_compress_response(self): |
| 58 | + """ |
| 59 | + Compression is performed on responses with compressible content. |
| 60 | + """ |
| 61 | + r = await AsyncGZipMiddleware(self.get_response)(self.req) |
| 62 | + assert self.decompress(r.content) == self.compressible_string |
| 63 | + assert r.get("Content-Encoding") == "gzip" |
| 64 | + assert r.get("Content-Length") == str(len(r.content)) |
| 65 | + |
| 66 | + async def test_compress_streaming_response(self): |
| 67 | + """ |
| 68 | + Compression is performed on responses with streaming content. |
| 69 | + """ |
| 70 | + |
| 71 | + async def get_stream_response(request): |
| 72 | + resp = StreamingHttpResponse(self.sequence) |
| 73 | + resp["Content-Type"] = "text/html; charset=UTF-8" |
| 74 | + return resp |
| 75 | + |
| 76 | + r = await AsyncGZipMiddleware(get_stream_response)(self.req) |
| 77 | + assert self.decompress(b"".join(r)) == b"".join(self.sequence) |
| 78 | + assert r.get("Content-Encoding") == "gzip" |
| 79 | + assert r.has_header("Content-Length") is False |
| 80 | + |
| 81 | + async def test_compress_async_streaming_response(self): |
| 82 | + """ |
| 83 | + Compression is performed on responses with async streaming content. |
| 84 | + """ |
| 85 | + |
| 86 | + async def get_stream_response(request): |
| 87 | + async def iterator(): |
| 88 | + for chunk in self.sequence: |
| 89 | + yield chunk |
| 90 | + |
| 91 | + resp = StreamingHttpResponse(iterator()) |
| 92 | + resp["Content-Type"] = "text/html; charset=UTF-8" |
| 93 | + return resp |
| 94 | + |
| 95 | + r = await AsyncGZipMiddleware(get_stream_response)(self.req) |
| 96 | + assert self.decompress(b"".join([chunk async for chunk in r])) == b"".join( |
| 97 | + self.sequence |
| 98 | + ) |
| 99 | + assert r.get("Content-Encoding") == "gzip" |
| 100 | + assert r.has_header("Content-Length") is False |
| 101 | + |
| 102 | + async def test_compress_streaming_response_unicode(self): |
| 103 | + """ |
| 104 | + Compression is performed on responses with streaming Unicode content. |
| 105 | + """ |
| 106 | + |
| 107 | + async def get_stream_response_unicode(request): |
| 108 | + resp = StreamingHttpResponse(self.sequence_unicode) |
| 109 | + resp["Content-Type"] = "text/html; charset=UTF-8" |
| 110 | + return resp |
| 111 | + |
| 112 | + r = await AsyncGZipMiddleware(get_stream_response_unicode)(self.req) |
| 113 | + |
| 114 | + assert self.decompress(b"".join(r)) == b"".join( |
| 115 | + x.encode() for x in self.sequence_unicode |
| 116 | + ) |
| 117 | + assert r.get("Content-Encoding") == "gzip" |
| 118 | + assert r.has_header("Content-Length") is False |
| 119 | + |
| 120 | + async def test_compress_file_response(self): |
| 121 | + """ |
| 122 | + Compression is performed on FileResponse. |
| 123 | + """ |
| 124 | + with open(__file__, "rb") as file1: |
| 125 | + |
| 126 | + async def get_response(req): |
| 127 | + file_resp = FileResponse(file1) |
| 128 | + file_resp["Content-Type"] = "text/html; charset=UTF-8" |
| 129 | + return file_resp |
| 130 | + |
| 131 | + r = await AsyncGZipMiddleware(get_response)(self.req) |
| 132 | + with open(__file__, "rb") as file2: |
| 133 | + assert self.decompress(b"".join(r)) == file2.read() |
| 134 | + assert r.get("Content-Encoding") == "gzip" |
| 135 | + assert r.file_to_stream is not file1 |
| 136 | + |
| 137 | + async def test_compress_non_200_response(self): |
| 138 | + """ |
| 139 | + Compression is performed on responses with a status other than 200 |
| 140 | + (#10762). |
| 141 | + """ |
| 142 | + self.resp.status_code = 404 |
| 143 | + r = await AsyncGZipMiddleware(self.get_response)(self.req) |
| 144 | + assert self.decompress(r.content) == self.compressible_string |
| 145 | + assert r.get("Content-Encoding") == "gzip" |
| 146 | + |
| 147 | + async def test_no_compress_short_response(self): |
| 148 | + """ |
| 149 | + Compression isn't performed on responses with short content. |
| 150 | + """ |
| 151 | + self.resp.content = self.short_string |
| 152 | + r = await AsyncGZipMiddleware(self.get_response)(self.req) |
| 153 | + assert r.content == self.short_string |
| 154 | + assert r.get("Content-Encoding") is None |
| 155 | + |
| 156 | + async def test_no_compress_compressed_response(self): |
| 157 | + """ |
| 158 | + Compression isn't performed on responses that are already compressed. |
| 159 | + """ |
| 160 | + self.resp["Content-Encoding"] = "deflate" |
| 161 | + r = await AsyncGZipMiddleware(self.get_response)(self.req) |
| 162 | + assert r.content == self.compressible_string |
| 163 | + assert r.get("Content-Encoding") == "deflate" |
| 164 | + |
| 165 | + async def test_no_compress_incompressible_response(self): |
| 166 | + """ |
| 167 | + Compression isn't performed on responses with incompressible content. |
| 168 | + """ |
| 169 | + self.resp.content = self.incompressible_string |
| 170 | + r = await AsyncGZipMiddleware(self.get_response)(self.req) |
| 171 | + assert r.content == self.incompressible_string |
| 172 | + assert r.get("Content-Encoding") is None |
| 173 | + |
| 174 | + async def test_compress_deterministic(self): |
| 175 | + """ |
| 176 | + Compression results are the same for the same content and don't |
| 177 | + include a modification time (since that would make the results |
| 178 | + of compression non-deterministic and prevent |
| 179 | + ConditionalGetMiddleware from recognizing conditional matches |
| 180 | + on gzipped content). |
| 181 | + """ |
| 182 | + |
| 183 | + class DeterministicGZipMiddleware(AsyncGZipMiddleware): |
| 184 | + max_random_bytes = 0 |
| 185 | + |
| 186 | + r1 = await DeterministicGZipMiddleware(self.get_response)(self.req) |
| 187 | + r2 = await DeterministicGZipMiddleware(self.get_response)(self.req) |
| 188 | + assert r1.content == r2.content |
| 189 | + assert self.get_mtime(r1.content) == 0 |
| 190 | + assert self.get_mtime(r2.content) == 0 |
| 191 | + |
| 192 | + async def test_random_bytes(self, mocker): |
| 193 | + """A random number of bytes is added to mitigate the BREACH attack.""" |
| 194 | + mocker.patch( |
| 195 | + "django.utils.text.secrets.randbelow", autospec=True, return_value=3 |
| 196 | + ) |
| 197 | + r = await AsyncGZipMiddleware(self.get_response)(self.req) |
| 198 | + # The fourth byte of a gzip stream contains flags. |
| 199 | + assert r.content[3] == gzip.FNAME |
| 200 | + # A 3 byte filename "aaa" and a null byte are added. |
| 201 | + assert r.content[10:14] == b"aaa\x00" |
| 202 | + assert self.decompress(r.content) == self.compressible_string |
| 203 | + |
| 204 | + async def test_random_bytes_streaming_response(self, mocker): |
| 205 | + """A random number of bytes is added to mitigate the BREACH attack.""" |
| 206 | + |
| 207 | + async def get_stream_response(request): |
| 208 | + resp = StreamingHttpResponse(self.sequence) |
| 209 | + resp["Content-Type"] = "text/html; charset=UTF-8" |
| 210 | + return resp |
| 211 | + |
| 212 | + mocker.patch( |
| 213 | + "django.utils.text.secrets.randbelow", autospec=True, return_value=3 |
| 214 | + ) |
| 215 | + r = await AsyncGZipMiddleware(get_stream_response)(self.req) |
| 216 | + content = b"".join(r) |
| 217 | + # The fourth byte of a gzip stream contains flags. |
| 218 | + assert content[3] == gzip.FNAME |
| 219 | + # A 3 byte filename "aaa" and a null byte are added. |
| 220 | + assert content[10:14] == b"aaa\x00" |
| 221 | + assert self.decompress(content) == b"".join(self.sequence) |
| 222 | + |
| 223 | + |
| 224 | +class TestETagGZipMiddleware: |
| 225 | + """ |
| 226 | + ETags are handled properly by GZipMiddleware. |
| 227 | + """ |
| 228 | + |
| 229 | + rf = AsyncRequestFactory() |
| 230 | + compressible_string = b"a" * 500 |
| 231 | + |
| 232 | + async def test_strong_etag_modified(self): |
| 233 | + """ |
| 234 | + GZipMiddleware makes a strong ETag weak. |
| 235 | + """ |
| 236 | + |
| 237 | + async def get_response(req): |
| 238 | + response = HttpResponse(self.compressible_string) |
| 239 | + response.headers["ETag"] = '"eggs"' |
| 240 | + return response |
| 241 | + |
| 242 | + request = self.rf.get("/", headers={"accept-encoding": "gzip, deflate"}) |
| 243 | + gzip_response = await AsyncGZipMiddleware(get_response)(request) |
| 244 | + assert gzip_response.headers["ETag"] == 'W/"eggs"' |
| 245 | + |
| 246 | + async def test_weak_etag_not_modified(self): |
| 247 | + """ |
| 248 | + GZipMiddleware doesn't modify a weak ETag. |
| 249 | + """ |
| 250 | + |
| 251 | + async def get_response(req): |
| 252 | + response = HttpResponse(self.compressible_string) |
| 253 | + response.headers["ETag"] = 'W/"eggs"' |
| 254 | + return response |
| 255 | + |
| 256 | + request = self.rf.get("/", headers={"accept-encoding": "gzip, deflate"}) |
| 257 | + gzip_response = await AsyncGZipMiddleware(get_response)(request) |
| 258 | + assert gzip_response.headers["ETag"] == 'W/"eggs"' |
| 259 | + |
| 260 | + async def test_etag_match(self): |
| 261 | + """ |
| 262 | + GZipMiddleware allows 304 Not Modified responses. |
| 263 | + """ |
| 264 | + |
| 265 | + async def get_response(req): |
| 266 | + return HttpResponse(self.compressible_string) |
| 267 | + |
| 268 | + async def get_cond_response(req): |
| 269 | + return await AsyncConditionalGetMiddleware(get_response)(req) |
| 270 | + |
| 271 | + request = self.rf.get("/", headers={"accept-encoding": "gzip, deflate"}) |
| 272 | + response = await AsyncGZipMiddleware(get_cond_response)(request) |
| 273 | + gzip_etag = response.headers["ETag"] |
| 274 | + next_request = self.rf.get( |
| 275 | + "/", |
| 276 | + headers={"accept-encoding": "gzip, deflate", "if-none-match": gzip_etag}, |
| 277 | + ) |
| 278 | + next_response = await AsyncConditionalGetMiddleware(get_response)(next_request) |
| 279 | + assert next_response.status_code == 304 |
0 commit comments