7013 lines
313 KiB
Python
7013 lines
313 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2013 OpenStack Foundation
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
# implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import base64
|
|
from datetime import datetime
|
|
import json
|
|
import time
|
|
import unittest
|
|
import string
|
|
|
|
import mock
|
|
from mock import patch
|
|
|
|
import six
|
|
from io import BytesIO
|
|
|
|
from swift.common import swob, registry
|
|
from swift.common.header_key_dict import HeaderKeyDict
|
|
from swift.common.middleware import slo
|
|
from swift.common.request_helpers import update_etag_is_at_header
|
|
from swift.common.swob import Request, HTTPException, str_to_wsgi, \
|
|
bytes_to_wsgi
|
|
from swift.common.utils import quote, closing_if_possible, close_if_possible, \
|
|
parse_content_type, iter_multipart_mime_documents, parse_mime_headers, \
|
|
Timestamp, get_expirer_container, md5
|
|
from test.unit.common.middleware.helpers import FakeSwift
|
|
|
|
|
|
test_xml_data = '''<?xml version="1.0" encoding="UTF-8"?>
|
|
<static_large_object>
|
|
<object_segment>
|
|
<path>/cont/object</path>
|
|
<etag>etagoftheobjectsegment</etag>
|
|
<size_bytes>100</size_bytes>
|
|
</object_segment>
|
|
</static_large_object>
|
|
'''
|
|
test_json_data = json.dumps([{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
|
|
|
|
def fake_start_response(*args, **kwargs):
|
|
pass
|
|
|
|
|
|
def md5hex(s):
|
|
if not isinstance(s, bytes):
|
|
s = s.encode('ascii')
|
|
return md5(s, usedforsecurity=False).hexdigest()
|
|
|
|
|
|
class SloTestCase(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.app = FakeSwift()
|
|
slo_conf = {'rate_limit_under_size': '0'}
|
|
self.slo = slo.filter_factory(slo_conf)(self.app)
|
|
self.slo.logger = self.app.logger
|
|
|
|
def call_app(self, req, app=None):
|
|
if app is None:
|
|
app = self.app
|
|
|
|
req.headers.setdefault("User-Agent", "Mozzarella Foxfire")
|
|
|
|
status = [None]
|
|
headers = [None]
|
|
|
|
def start_response(s, h, ei=None):
|
|
status[0] = s
|
|
headers[0] = h
|
|
|
|
body_iter = app(req.environ, start_response)
|
|
body = b''
|
|
# appease the close-checker
|
|
with closing_if_possible(body_iter):
|
|
for chunk in body_iter:
|
|
body += chunk
|
|
return status[0], headers[0], body
|
|
|
|
def call_slo(self, req, **kwargs):
|
|
return self.call_app(req, app=self.slo, **kwargs)
|
|
|
|
|
|
class TestSloMiddleware(SloTestCase):
|
|
|
|
def setUp(self):
|
|
super(TestSloMiddleware, self).setUp()
|
|
|
|
self.app.register(
|
|
'GET', '/', swob.HTTPOk, {}, b'passed')
|
|
self.app.register(
|
|
'PUT', '/', swob.HTTPOk, {}, b'passed')
|
|
|
|
def test_handle_multipart_no_obj(self):
|
|
req = Request.blank('/')
|
|
resp_iter = self.slo(req.environ, fake_start_response)
|
|
self.assertEqual(self.app.calls, [('GET', '/')])
|
|
self.assertEqual(b''.join(resp_iter), b'passed')
|
|
|
|
def test_slo_header_assigned(self):
|
|
req = Request.blank(
|
|
'/v1/a/c/o', headers={'x-static-large-object': "true"},
|
|
environ={'REQUEST_METHOD': 'PUT'})
|
|
resp = b''.join(self.slo(req.environ, fake_start_response))
|
|
self.assertTrue(
|
|
resp.startswith(b'X-Static-Large-Object is a reserved header'))
|
|
|
|
def test_slo_PUT_env_override(self):
|
|
path = '/v1/a/c/o'
|
|
body = b'manifest body not checked when override flag set'
|
|
resp_status = []
|
|
|
|
def start_response(status, headers, *args):
|
|
resp_status.append(status)
|
|
|
|
req = Request.blank(
|
|
path, headers={'x-static-large-object': "true"},
|
|
environ={'REQUEST_METHOD': 'PUT', 'swift.slo_override': True},
|
|
body=body)
|
|
self.app.register('PUT', path, swob.HTTPCreated, {})
|
|
resp_iter = self.slo(req.environ, start_response)
|
|
self.assertEqual(b'', b''.join(resp_iter))
|
|
self.assertEqual(self.app.calls, [('PUT', path)])
|
|
self.assertEqual(body, self.app.uploaded[path][1])
|
|
self.assertEqual(resp_status[0], '201 Created')
|
|
|
|
def _put_bogus_slo(self, manifest_text,
|
|
manifest_path='/v1/a/c/the-manifest'):
|
|
with self.assertRaises(HTTPException) as catcher:
|
|
slo.parse_and_validate_input(manifest_text, manifest_path)
|
|
self.assertEqual(400, catcher.exception.status_int)
|
|
return catcher.exception.body.decode('utf-8')
|
|
|
|
def _put_slo(self, manifest_text, manifest_path='/v1/a/c/the-manifest'):
|
|
return slo.parse_and_validate_input(manifest_text, manifest_path)
|
|
|
|
def test_bogus_input(self):
|
|
self.assertEqual('Manifest must be valid JSON.\n',
|
|
self._put_bogus_slo('some non json'))
|
|
|
|
self.assertEqual('Manifest must be a list.\n',
|
|
self._put_bogus_slo('{}'))
|
|
|
|
self.assertEqual('Index 0: not a JSON object\n',
|
|
self._put_bogus_slo('["zombocom"]'))
|
|
|
|
def test_bogus_input_bad_keys(self):
|
|
self.assertEqual(
|
|
"Index 0: extraneous keys \"baz\", \"foo\"\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100,
|
|
'foo': 'bar', 'baz': 'quux'}])))
|
|
|
|
# This also catches typos
|
|
self.assertEqual(
|
|
'Index 0: extraneous keys "egat"\n',
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'egat': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}])))
|
|
self.assertEqual(
|
|
'Index 0: extraneous keys "siez_bytes"\n',
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'etagoftheobjectsegment',
|
|
'siez_bytes': 100}])))
|
|
|
|
def test_bogus_input_ranges(self):
|
|
self.assertEqual(
|
|
"Index 0: invalid range\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'blah',
|
|
'size_bytes': 100, 'range': 'non-range value'}])))
|
|
|
|
self.assertEqual(
|
|
"Index 0: multiple ranges (only one allowed)\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'blah',
|
|
'size_bytes': 100, 'range': '1-20,30-40'}])))
|
|
|
|
def test_bogus_input_unsatisfiable_range(self):
|
|
self.assertEqual(
|
|
"Index 0: unsatisfiable range\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'blah',
|
|
'size_bytes': 100, 'range': '8888-9999'}])))
|
|
|
|
# since size is optional, we have to be able to defer this check
|
|
segs = self._put_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'blah',
|
|
'size_bytes': None, 'range': '8888-9999'}]))
|
|
self.assertEqual(1, len(segs))
|
|
|
|
def test_bogus_input_path(self):
|
|
self.assertEqual(
|
|
"Index 0: path does not refer to an object. Path must be of the "
|
|
"form /container/object.\n"
|
|
"Index 1: path does not refer to an object. Path must be of the "
|
|
"form /container/object.\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont', 'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'path': '/c-trailing-slash/', 'etag': 'e',
|
|
'size_bytes': 100},
|
|
{'path': '/con/obj', 'etag': 'e',
|
|
'size_bytes': 100},
|
|
{'path': '/con/obj-trailing-slash/', 'etag': 'e',
|
|
'size_bytes': 100},
|
|
{'path': '/con/obj/with/slashes', 'etag': 'e',
|
|
'size_bytes': 100}])))
|
|
|
|
def test_bogus_input_multiple(self):
|
|
self.assertEqual(
|
|
"Index 0: invalid range\nIndex 1: not a JSON object\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100, 'range': 'non-range value'},
|
|
None])))
|
|
|
|
def test_bogus_input_size_bytes(self):
|
|
self.assertEqual(
|
|
"Index 0: invalid size_bytes\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'blah', 'size_bytes': "fht"},
|
|
{'path': '/cont/object', 'etag': 'blah', 'size_bytes': None},
|
|
{'path': '/cont/object', 'etag': 'blah', 'size_bytes': 100}],
|
|
)))
|
|
|
|
self.assertEqual(
|
|
"Index 0: invalid size_bytes\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'blah', 'size_bytes': []}],
|
|
)))
|
|
|
|
def test_bogus_input_self_referential(self):
|
|
self.assertEqual(
|
|
"Index 0: manifest must not include itself as a segment\n",
|
|
self._put_bogus_slo(json.dumps(
|
|
[{'path': '/c/the-manifest', 'etag': 'gate',
|
|
'size_bytes': 100, 'range': 'non-range value'}])))
|
|
|
|
def test_bogus_input_self_referential_non_ascii(self):
|
|
self.assertEqual(
|
|
"Index 0: manifest must not include itself as a segment\n",
|
|
self._put_bogus_slo(
|
|
json.dumps([{'path': u'/c/あ_1',
|
|
'etag': 'a', 'size_bytes': 1}]),
|
|
manifest_path=quote(u'/v1/a/c/あ_1')))
|
|
|
|
def test_bogus_input_self_referential_last_segment(self):
|
|
test_json_data = json.dumps([
|
|
{'path': '/c/seg_1', 'etag': 'a', 'size_bytes': 1},
|
|
{'path': '/c/seg_2', 'etag': 'a', 'size_bytes': 1},
|
|
{'path': '/c/seg_3', 'etag': 'a', 'size_bytes': 1},
|
|
{'path': '/c/the-manifest', 'etag': 'a', 'size_bytes': 1},
|
|
]).encode('ascii')
|
|
self.assertEqual(
|
|
"Index 3: manifest must not include itself as a segment\n",
|
|
self._put_bogus_slo(
|
|
test_json_data,
|
|
manifest_path=quote('/v1/a/c/the-manifest')))
|
|
|
|
def test_bogus_input_undersize_segment(self):
|
|
self.assertEqual(
|
|
"Index 1: too small; each segment "
|
|
"must be at least 1 byte.\n"
|
|
"Index 2: too small; each segment "
|
|
"must be at least 1 byte.\n",
|
|
self._put_bogus_slo(
|
|
json.dumps([
|
|
{'path': u'/c/s1', 'etag': 'a', 'size_bytes': 1},
|
|
{'path': u'/c/s2', 'etag': 'b', 'size_bytes': 0},
|
|
{'path': u'/c/s3', 'etag': 'c', 'size_bytes': 0},
|
|
# No error for this one since size_bytes is unspecified
|
|
{'path': u'/c/s4', 'etag': 'd', 'size_bytes': None},
|
|
{'path': u'/c/s5', 'etag': 'e', 'size_bytes': 1000}])))
|
|
|
|
def test_valid_input(self):
|
|
data = json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}])
|
|
self.assertEqual(
|
|
'/cont/object',
|
|
slo.parse_and_validate_input(data, '/v1/a/cont/man')[0]['path'])
|
|
|
|
data = json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100, 'range': '0-40'}])
|
|
parsed = slo.parse_and_validate_input(data, '/v1/a/cont/man')
|
|
self.assertEqual('/cont/object', parsed[0]['path'])
|
|
self.assertEqual([(0, 40)], parsed[0]['range'].ranges)
|
|
|
|
data = json.dumps(
|
|
[{'path': '/cont/object', 'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': None, 'range': '0-40'}])
|
|
parsed = slo.parse_and_validate_input(data, '/v1/a/cont/man')
|
|
self.assertEqual('/cont/object', parsed[0]['path'])
|
|
self.assertIsNone(parsed[0]['size_bytes'])
|
|
self.assertEqual([(0, 40)], parsed[0]['range'].ranges)
|
|
|
|
def test_container_listing(self):
|
|
listing_json = json.dumps([{
|
|
"bytes": 104857600,
|
|
"content_type": "application/x-troff-me",
|
|
"hash": "8de7b0b1551660da51d8d96a53b85531; this=that;"
|
|
"slo_etag=dc9947c2b53a3f55fe20c1394268e216",
|
|
"last_modified": "2018-07-12T03:14:39.532020",
|
|
"name": "test.me"
|
|
}]).encode('ascii')
|
|
self.app.register(
|
|
'GET', '/v1/a/c',
|
|
swob.HTTPOk,
|
|
{'Content-Type': 'application/json',
|
|
'Content-Length': len(listing_json)},
|
|
listing_json)
|
|
req = Request.blank('/v1/a/c', method='GET')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(json.loads(body), [{
|
|
"slo_etag": '"dc9947c2b53a3f55fe20c1394268e216"',
|
|
"hash": "8de7b0b1551660da51d8d96a53b85531; this=that",
|
|
"name": "test.me",
|
|
"bytes": 104857600,
|
|
"last_modified": "2018-07-12T03:14:39.532020",
|
|
"content_type": "application/x-troff-me",
|
|
}])
|
|
|
|
|
|
class TestSloPutManifest(SloTestCase):
|
|
|
|
def setUp(self):
|
|
super(TestSloPutManifest, self).setUp()
|
|
|
|
self.app.register(
|
|
'GET', '/', swob.HTTPOk, {}, b'passed')
|
|
self.app.register(
|
|
'PUT', '/', swob.HTTPOk, {}, b'passed')
|
|
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/cont/missing-object',
|
|
swob.HTTPNotFound, {}, None)
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/cont/object',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '100', 'Etag': 'etagoftheobjectsegment'},
|
|
None)
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/cont/object2',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '100', 'Etag': 'etagoftheobjectsegment'},
|
|
None)
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/cont/object\xe2\x99\xa1',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '100', 'Etag': 'etagoftheobjectsegment'},
|
|
None)
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/cont/small_object',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '10', 'Etag': 'etagoftheobjectsegment'},
|
|
None)
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/cont/empty_object',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '0', 'Etag': 'etagoftheobjectsegment'},
|
|
None)
|
|
self.app.register(
|
|
'HEAD', u'/v1/AUTH_test/cont/あ_1',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '1', 'Etag': 'a'},
|
|
None)
|
|
self.app.register(
|
|
'PUT', '/v1/AUTH_test/c/man', swob.HTTPCreated,
|
|
{'Last-Modified': 'Fri, 01 Feb 2012 20:38:36 GMT'}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/c/man', swob.HTTPNoContent, {}, None)
|
|
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/checktest/a_1',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '1', 'Etag': 'a'},
|
|
None)
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/checktest/badreq',
|
|
swob.HTTPBadRequest, {}, None)
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/checktest/b_2',
|
|
swob.HTTPOk,
|
|
{'Content-Length': '2', 'Etag': 'b',
|
|
'Last-Modified': 'Fri, 01 Feb 2012 20:38:36 GMT'},
|
|
None)
|
|
|
|
_manifest_json = json.dumps(
|
|
[{'name': '/checktest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'}]).encode('ascii')
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/checktest/slob',
|
|
swob.HTTPOk,
|
|
{'X-Static-Large-Object': 'true', 'Etag': 'slob-etag',
|
|
'Content-Type': 'cat/picture',
|
|
'Content-Length': len(_manifest_json)},
|
|
_manifest_json)
|
|
|
|
self.app.register(
|
|
'PUT', '/v1/AUTH_test/checktest/man_3', swob.HTTPCreated, {}, None)
|
|
|
|
def test_put_manifest_too_quick_fail(self):
|
|
req = Request.blank('/v1/a/c/o?multipart-manifest=put', method='PUT')
|
|
req.content_length = self.slo.max_manifest_size + 1
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '413 Request Entity Too Large')
|
|
|
|
with patch.object(self.slo, 'max_manifest_segments', 0):
|
|
req = Request.blank('/v1/a/c/o?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '413 Request Entity Too Large')
|
|
|
|
req = Request.blank('/v1/a/c/o?multipart-manifest=put', method='PUT',
|
|
headers={'X-Copy-From': 'lala'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '405 Method Not Allowed')
|
|
|
|
# we already validated that there are enough path segments in __call__
|
|
for path in ('/', '/v1/', '/v1/a/', '/v1/a/c/'):
|
|
req = Request.blank(
|
|
path + '?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=test_json_data)
|
|
with self.assertRaises(ValueError):
|
|
list(self.slo.handle_multipart_put(req, fake_start_response))
|
|
|
|
req = Request.blank(
|
|
path.rstrip('/') + '?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=test_json_data)
|
|
with self.assertRaises(ValueError):
|
|
list(self.slo.handle_multipart_put(req, fake_start_response))
|
|
|
|
def test_handle_multipart_put_success(self):
|
|
override_header = 'X-Object-Sysmeta-Container-Update-Override-Etag'
|
|
headers = {
|
|
'Accept': 'test',
|
|
override_header: '; params=are important',
|
|
}
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, headers=headers,
|
|
body=test_json_data)
|
|
for h in ('X-Static-Large-Object', 'X-Object-Sysmeta-Slo-Etag',
|
|
'X-Object-Sysmeta-Slo-Size'):
|
|
# Sanity
|
|
self.assertNotIn(h, req.headers)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
gen_etag = '"' + md5hex('etagoftheobjectsegment') + '"'
|
|
self.assertIn(('Etag', gen_etag), headers)
|
|
self.assertIn('X-Static-Large-Object', req.headers)
|
|
self.assertEqual(req.headers['X-Static-Large-Object'], 'True')
|
|
self.assertIn('Etag', req.headers)
|
|
self.assertIn('X-Object-Sysmeta-Slo-Etag', req.headers)
|
|
self.assertIn('X-Object-Sysmeta-Container-Update-Override-Etag',
|
|
req.headers)
|
|
self.assertEqual(req.headers['X-Object-Sysmeta-Slo-Etag'],
|
|
gen_etag.strip('"'))
|
|
self.assertEqual(
|
|
req.headers['X-Object-Sysmeta-Container-Update-Override-Etag'],
|
|
'%s; params=are important; slo_etag=%s' % (
|
|
req.headers['Etag'], gen_etag.strip('"')))
|
|
self.assertIn('X-Object-Sysmeta-Slo-Size', req.headers)
|
|
self.assertEqual(req.headers['X-Object-Sysmeta-Slo-Size'], '100')
|
|
self.assertIn('Content-Type', req.headers)
|
|
self.assertTrue(
|
|
req.headers['Content-Type'].endswith(';swift_bytes=100'),
|
|
'Content-Type %r does not end with swift_bytes=100' %
|
|
req.headers['Content-Type'])
|
|
|
|
@patch('swift.common.middleware.slo.time')
|
|
def test_handle_multipart_put_fast_heartbeat(self, mock_time):
|
|
mock_time.time.side_effect = [
|
|
0, # start time
|
|
1, # first segment's fast
|
|
2, # second segment's also fast!
|
|
]
|
|
test_json_data = json.dumps([{'path': u'/cont/object\u2661',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put&heartbeat=on',
|
|
environ={'REQUEST_METHOD': 'PUT'}, headers={'Accept': 'test'},
|
|
body=test_json_data)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('202 Accepted', status)
|
|
headers_found = [h.lower() for h, v in headers]
|
|
self.assertNotIn('etag', headers_found)
|
|
gen_etag = '"' + md5hex('etagoftheobjectsegment' * 2) + '"'
|
|
self.assertTrue(body.startswith(b' \r\n\r\n'),
|
|
'Expected body to start with single space and two '
|
|
'blank lines; got %r' % body)
|
|
self.assertIn(b'\nResponse Status: 201 Created\n', body)
|
|
self.assertIn(b'\nResponse Body: \n', body)
|
|
self.assertIn(('\nEtag: %s\n' % gen_etag).encode('ascii'), body)
|
|
self.assertIn(b'\nLast Modified: Fri, 01 Feb 2012 20:38:36 GMT\n',
|
|
body)
|
|
|
|
@patch('swift.common.middleware.slo.time')
|
|
def test_handle_multipart_long_running_put_success(self, mock_time):
|
|
mock_time.time.side_effect = [
|
|
0, # start time
|
|
1, # first segment's fast
|
|
20, # second segment's slow
|
|
]
|
|
test_json_data = json.dumps([{'path': u'/cont/object\u2661',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put&heartbeat=on',
|
|
environ={'REQUEST_METHOD': 'PUT'}, headers={'Accept': 'test'},
|
|
body=test_json_data)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('202 Accepted', status)
|
|
headers_found = [h.lower() for h, v in headers]
|
|
self.assertNotIn('etag', headers_found)
|
|
gen_etag = '"' + md5hex('etagoftheobjectsegment' * 2) + '"'
|
|
self.assertTrue(body.startswith(b' \r\n\r\n'),
|
|
'Expected body to start with two spaces and two '
|
|
'blank lines; got %r' % body)
|
|
self.assertIn(b'\nResponse Status: 201 Created\n', body)
|
|
self.assertIn(b'\nResponse Body: \n', body)
|
|
self.assertIn(('\nEtag: %s\n' % gen_etag).encode('ascii'), body)
|
|
self.assertIn(b'\nLast Modified: Fri, 01 Feb 2012 20:38:36 GMT\n',
|
|
body)
|
|
|
|
@patch('swift.common.middleware.slo.time')
|
|
def test_handle_multipart_long_running_put_success_json(self, mock_time):
|
|
mock_time.time.side_effect = [
|
|
0, # start time
|
|
11, # first segment's slow
|
|
22, # second segment's also slow
|
|
]
|
|
test_json_data = json.dumps([{'path': u'/cont/object\u2661',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put&heartbeat=on',
|
|
environ={'REQUEST_METHOD': 'PUT'},
|
|
headers={'Accept': 'application/json'},
|
|
body=test_json_data)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('202 Accepted', status)
|
|
headers_found = [h.lower() for h, v in headers]
|
|
self.assertNotIn('etag', headers_found)
|
|
gen_etag = '"' + md5hex('etagoftheobjectsegment' * 2) + '"'
|
|
self.assertTrue(body.startswith(b' \r\n\r\n'),
|
|
'Expected body to start with three spaces and two '
|
|
'blank lines; got %r' % body)
|
|
body = json.loads(body)
|
|
self.assertEqual(body['Response Status'], '201 Created')
|
|
self.assertEqual(body['Response Body'], '')
|
|
self.assertEqual(body['Etag'], gen_etag)
|
|
self.assertEqual(body['Last Modified'],
|
|
'Fri, 01 Feb 2012 20:38:36 GMT')
|
|
|
|
@patch('swift.common.middleware.slo.time')
|
|
def test_handle_multipart_long_running_put_failure(self, mock_time):
|
|
mock_time.time.side_effect = [
|
|
0, # start time
|
|
1, # first segment's fast
|
|
20, # second segment's slow
|
|
]
|
|
test_json_data = json.dumps([{'path': u'/cont/missing-object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 99}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put&heartbeat=on',
|
|
environ={'REQUEST_METHOD': 'PUT'}, headers={'Accept': 'test'},
|
|
body=test_json_data)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('202 Accepted', status)
|
|
headers_found = [h.lower() for h, v in headers]
|
|
self.assertNotIn('etag', headers_found)
|
|
body = body.split(b'\n')
|
|
self.assertEqual([b' \r', b'\r'], body[:2],
|
|
'Expected body to start with two spaces and two '
|
|
'blank lines; got %r' % b'\n'.join(body))
|
|
self.assertIn(b'Response Status: 400 Bad Request', body[2:5])
|
|
self.assertIn(b'Response Body: Bad Request', body)
|
|
self.assertIn(b'The server could not comply with the request since it '
|
|
b'is either malformed or otherwise incorrect.', body)
|
|
self.assertFalse(any(line.startswith(b'Etag: ') for line in body))
|
|
self.assertFalse(any(line.startswith(b'Last Modified: ')
|
|
for line in body))
|
|
self.assertEqual(body[-4], b'Errors:')
|
|
self.assertEqual(sorted(body[-3:-1]), [
|
|
b'/cont/missing-object, 404 Not Found',
|
|
b'/cont/object, Size Mismatch',
|
|
])
|
|
self.assertEqual(body[-1], b'')
|
|
|
|
@patch('swift.common.middleware.slo.time')
|
|
def test_handle_multipart_long_running_put_failure_json(self, mock_time):
|
|
mock_time.time.side_effect = [
|
|
0, # start time
|
|
11, # first segment's slow
|
|
22, # second segment's also slow
|
|
]
|
|
test_json_data = json.dumps([{'path': u'/cont/object\u2661',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 99},
|
|
{'path': '/cont/object',
|
|
'etag': 'some other etag',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put&heartbeat=on',
|
|
environ={'REQUEST_METHOD': 'PUT'},
|
|
headers={'Accept': 'application/json'},
|
|
body=test_json_data)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('202 Accepted', status)
|
|
headers_found = [h.lower() for h, v in headers]
|
|
self.assertNotIn('etag', headers_found)
|
|
self.assertTrue(body.startswith(b' \r\n\r\n'),
|
|
'Expected body to start with three spaces and two '
|
|
'blank lines; got %r' % body)
|
|
body = json.loads(body)
|
|
self.assertEqual(body['Response Status'], '400 Bad Request')
|
|
self.assertEqual(body['Response Body'], 'Bad Request\nThe server '
|
|
'could not comply with the request since it is '
|
|
'either malformed or otherwise incorrect.')
|
|
self.assertNotIn('Etag', body)
|
|
self.assertNotIn('Last Modified', body)
|
|
self.assertEqual(sorted(body['Errors']), [
|
|
['/cont/object', 'Etag Mismatch'],
|
|
[quote(u'/cont/object\u2661'.encode('utf8')).decode('ascii'),
|
|
'Size Mismatch'],
|
|
])
|
|
|
|
@patch('swift.common.middleware.slo.time')
|
|
def test_handle_multipart_long_running_put_bad_etag_json(self, mock_time):
|
|
mock_time.time.side_effect = [
|
|
0, # start time
|
|
11, # first segment's slow
|
|
22, # second segment's also slow
|
|
]
|
|
test_json_data = json.dumps([{'path': u'/cont/object\u2661',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put&heartbeat=on',
|
|
environ={'REQUEST_METHOD': 'PUT'},
|
|
headers={'Accept': 'application/json', 'ETag': 'bad etag'},
|
|
body=test_json_data)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('202 Accepted', status)
|
|
headers_found = [h.lower() for h, v in headers]
|
|
self.assertNotIn('etag', headers_found)
|
|
self.assertTrue(body.startswith(b' \r\n\r\n'),
|
|
'Expected body to start with three spaces and two '
|
|
'blank lines; got %r' % body)
|
|
body = json.loads(body)
|
|
self.assertEqual(body['Response Status'], '422 Unprocessable Entity')
|
|
self.assertEqual('Unprocessable Entity\nUnable to process the '
|
|
'contained instructions', body['Response Body'])
|
|
self.assertNotIn('Etag', body)
|
|
self.assertNotIn('Last Modified', body)
|
|
self.assertEqual(body['Errors'], [])
|
|
|
|
def test_manifest_put_no_etag_success(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
resp = req.get_response(self.slo)
|
|
self.assertEqual(resp.status_int, 201)
|
|
|
|
def test_manifest_put_with_etag_success(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
req.headers['Etag'] = md5hex('etagoftheobjectsegment')
|
|
resp = req.get_response(self.slo)
|
|
self.assertEqual(resp.status_int, 201)
|
|
|
|
def test_manifest_put_with_etag_with_quotes_success(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
req.headers['Etag'] = '"%s"' % md5hex('etagoftheobjectsegment')
|
|
resp = req.get_response(self.slo)
|
|
self.assertEqual(resp.status_int, 201)
|
|
|
|
def test_manifest_put_bad_etag_fail(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
req.headers['Etag'] = md5hex('NOTetagoftheobjectsegment')
|
|
resp = req.get_response(self.slo)
|
|
self.assertEqual(resp.status_int, 422)
|
|
|
|
def test_handle_multipart_put_disallow_empty_first_segment(self):
|
|
test_json_data = json.dumps([{'path': '/cont/small_object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 0},
|
|
{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank('/v1/a/c/o?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
|
|
def test_handle_multipart_put_allow_empty_last_segment(self):
|
|
test_json_data = json.dumps([{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'path': '/cont/empty_object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 0}]).encode('ascii')
|
|
req = Request.blank('/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '201 Created')
|
|
|
|
def test_handle_multipart_put_invalid_data(self):
|
|
def do_test(bad_data):
|
|
test_json_data = json.dumps([{'path': '/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100},
|
|
{'data': bad_data}]).encode('ascii')
|
|
req = Request.blank('/v1/a/c/o', body=test_json_data)
|
|
with self.assertRaises(HTTPException) as catcher:
|
|
self.slo.handle_multipart_put(req, fake_start_response)
|
|
self.assertEqual(catcher.exception.status_int, 400)
|
|
|
|
do_test('invalid') # insufficient padding
|
|
do_test(12345)
|
|
do_test(0)
|
|
do_test(True)
|
|
do_test(False)
|
|
do_test(None)
|
|
do_test({})
|
|
do_test([])
|
|
# Empties are no good, either
|
|
do_test('')
|
|
do_test('====')
|
|
|
|
def test_handle_multipart_put_success_unicode(self):
|
|
test_json_data = json.dumps([{'path': u'/cont/object\u2661',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, headers={'Accept': 'test'},
|
|
body=test_json_data)
|
|
self.assertNotIn('X-Static-Large-Object', req.headers)
|
|
self.call_slo(req)
|
|
self.assertIn('X-Static-Large-Object', req.headers)
|
|
self.assertEqual(req.environ['PATH_INFO'], '/v1/AUTH_test/c/man')
|
|
self.assertIn(('HEAD', '/v1/AUTH_test/cont/object\xe2\x99\xa1'),
|
|
self.app.calls)
|
|
|
|
def test_handle_multipart_put_no_xml(self):
|
|
req = Request.blank(
|
|
'/test_good/AUTH_test/c/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, headers={'Accept': 'test'},
|
|
body=test_xml_data)
|
|
no_xml = list(self.slo(req.environ, fake_start_response))
|
|
self.assertEqual(no_xml, [b'Manifest must be valid JSON.\n'])
|
|
|
|
def test_handle_multipart_put_bad_data(self):
|
|
bad_data = json.dumps([{'path': '/cont/object',
|
|
'etag': 'etagoftheobj',
|
|
'size_bytes': 'lala'}])
|
|
req = Request.blank(
|
|
'/test_good/AUTH_test/c/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=bad_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertIn(b'invalid size_bytes', body)
|
|
|
|
for bad_data in [
|
|
json.dumps([{'path': '/cont', 'etag': 'etagoftheobj',
|
|
'size_bytes': 100}]),
|
|
json.dumps('asdf'), json.dumps(None), json.dumps(5),
|
|
'not json', '1234', '', json.dumps({'path': None}),
|
|
json.dumps([{'path': '/cont/object', 'etag': None,
|
|
'size_bytes': 12}]),
|
|
json.dumps([{'path': '/cont/object', 'etag': 'asdf',
|
|
'size_bytes': 'sd'}]),
|
|
json.dumps([{'path': 12, 'etag': 'etagoftheobj',
|
|
'size_bytes': 100}]),
|
|
json.dumps([{'path': u'/cont/object\u2661',
|
|
'etag': 'etagoftheobj', 'size_bytes': 100}]),
|
|
json.dumps([{'path': 12, 'size_bytes': 100}]),
|
|
json.dumps([{'path': 12, 'size_bytes': 100}]),
|
|
json.dumps([{'path': '/c/o', 'etag': 123, 'size_bytes': 100}]),
|
|
json.dumps([{'path': None, 'etag': 'etagoftheobj',
|
|
'size_bytes': 100}])]:
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=bad_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=None)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '411 Length Required')
|
|
|
|
def test_handle_multipart_put_check_data(self):
|
|
good_data = json.dumps(
|
|
[{'path': '/checktest/a_1', 'etag': 'a', 'size_bytes': '1'},
|
|
{'path': '/checktest/b_2', 'etag': 'b', 'size_bytes': '2'}])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=good_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(self.app.call_count, 3)
|
|
|
|
# go behind SLO's back and see what actually got stored
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=get',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_app(req)
|
|
headers = dict(headers)
|
|
manifest_data = json.loads(body)
|
|
self.assertTrue(headers['Content-Type'].endswith(';swift_bytes=3'))
|
|
self.assertEqual(len(manifest_data), 2)
|
|
self.assertEqual(manifest_data[0]['hash'], 'a')
|
|
self.assertEqual(manifest_data[0]['bytes'], 1)
|
|
self.assertTrue(
|
|
not manifest_data[0]['last_modified'].startswith('2012'))
|
|
self.assertTrue(manifest_data[1]['last_modified'].startswith('2012'))
|
|
|
|
def test_handle_multipart_put_check_data_bad(self):
|
|
bad_data = json.dumps(
|
|
[{'path': '/checktest/a_1', 'etag': 'a', 'size_bytes': '2'},
|
|
{'path': '/checktest/badreq', 'etag': 'a', 'size_bytes': '1'},
|
|
{'path': '/checktest/b_2', 'etag': 'not-b', 'size_bytes': '2'},
|
|
{'path': '/checktest/slob', 'etag': 'not-slob',
|
|
'size_bytes': '12345'}])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'},
|
|
headers={'Accept': 'application/json'},
|
|
body=bad_data)
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(self.app.call_count, 5)
|
|
errors = json.loads(body)['Errors']
|
|
|
|
self.assertEqual([
|
|
[u'/checktest/a_1', u'Size Mismatch'],
|
|
[u'/checktest/b_2', u'Etag Mismatch'],
|
|
[u'/checktest/badreq', u'400 Bad Request'],
|
|
[u'/checktest/slob', u'Etag Mismatch'],
|
|
[u'/checktest/slob', u'Size Mismatch'],
|
|
], sorted(errors))
|
|
|
|
def test_handle_multipart_put_skip_size_check(self):
|
|
good_data = json.dumps([
|
|
# Explicit None will skip it
|
|
{'path': '/checktest/a_1', 'etag': 'a', 'size_bytes': None},
|
|
# ...as will omitting it entirely
|
|
{'path': '/checktest/b_2', 'etag': 'b'}])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=good_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(self.app.call_count, 3)
|
|
|
|
# Check that we still populated the manifest properly from our HEADs
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=get',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_app(req)
|
|
manifest_data = json.loads(body)
|
|
self.assertEqual(1, manifest_data[0]['bytes'])
|
|
self.assertEqual(2, manifest_data[1]['bytes'])
|
|
|
|
def test_handle_multipart_put_skip_size_check_still_uses_min_size(self):
|
|
test_json_data = json.dumps([{'path': '/cont/empty_object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': None},
|
|
{'path': '/cont/small_object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank('/v1/AUTH_test/c/o?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertIn(b'Too small; each segment must be at least 1 byte', body)
|
|
|
|
def test_handle_multipart_put_skip_size_check_no_early_bailout(self):
|
|
# The first is too small (it's 0 bytes), and
|
|
# the second has a bad etag. Make sure both errors show up in
|
|
# the response.
|
|
test_json_data = json.dumps([{'path': '/cont/empty_object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': None},
|
|
{'path': '/cont/object2',
|
|
'etag': 'wrong wrong wrong',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank('/v1/AUTH_test/c/o?multipart-manifest=put',
|
|
method='PUT', body=test_json_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertIn(b'at least 1 byte', body)
|
|
self.assertIn(b'Etag Mismatch', body)
|
|
|
|
def test_handle_multipart_put_skip_etag_check(self):
|
|
good_data = json.dumps([
|
|
# Explicit None will skip it
|
|
{'path': '/checktest/a_1', 'etag': None, 'size_bytes': 1},
|
|
# ...as will omitting it entirely
|
|
{'path': '/checktest/b_2', 'size_bytes': 2}])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=good_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(self.app.call_count, 3)
|
|
|
|
# Check that we still populated the manifest properly from our HEADs
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=get',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_app(req)
|
|
manifest_data = json.loads(body)
|
|
self.assertEqual('a', manifest_data[0]['hash'])
|
|
self.assertEqual('b', manifest_data[1]['hash'])
|
|
|
|
def test_handle_multipart_put_with_manipulator_callback(self):
|
|
def data_inserter(manifest):
|
|
for i in range(len(manifest), -1, -1):
|
|
manifest.insert(i, {'data': 'WA=='})
|
|
|
|
good_data = json.dumps([
|
|
{'path': '/checktest/a_1'},
|
|
{'path': '/checktest/b_2'}])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT',
|
|
'swift.callback.slo_manifest_hook': data_inserter},
|
|
body=good_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(self.app.call_count, 3)
|
|
|
|
# Check that we still populated the manifest properly from our HEADs
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=get',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_app(req)
|
|
manifest_data = json.loads(body)
|
|
self.assertEqual([
|
|
{k: v for k, v in item.items()
|
|
if k in ('name', 'bytes', 'hash', 'data')}
|
|
for item in manifest_data
|
|
], [
|
|
{'data': 'WA=='},
|
|
{'name': '/checktest/a_1', 'bytes': 1, 'hash': 'a'},
|
|
{'data': 'WA=='},
|
|
{'name': '/checktest/b_2', 'bytes': 2, 'hash': 'b'},
|
|
{'data': 'WA=='},
|
|
])
|
|
|
|
def test_handle_multipart_put_with_validator_callback(self):
|
|
def complainer(manifest):
|
|
return [(item['name'], "Don't wanna") for item in manifest]
|
|
|
|
good_data = json.dumps([
|
|
{'path': '/checktest/a_1'},
|
|
{'path': '/checktest/b_2'}])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT',
|
|
'swift.callback.slo_manifest_hook': complainer},
|
|
body=good_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(self.app.call_count, 2)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
body = body.split(b'\n')
|
|
self.assertIn(b"/checktest/a_1, Don't wanna", body)
|
|
self.assertIn(b"/checktest/b_2, Don't wanna", body)
|
|
|
|
def test_handle_unsatisfiable_ranges(self):
|
|
bad_data = json.dumps(
|
|
[{'path': '/checktest/a_1', 'etag': None,
|
|
'size_bytes': None, 'range': '1-'}])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=bad_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('400 Bad Request', status)
|
|
self.assertIn(b"Unsatisfiable Range", body)
|
|
|
|
def test_handle_multipart_put_success_conditional(self):
|
|
test_json_data = json.dumps([{'path': u'/cont/object',
|
|
'etag': 'etagoftheobjectsegment',
|
|
'size_bytes': 100}]).encode('ascii')
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/man?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, headers={'If-None-Match': '*'},
|
|
body=test_json_data)
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(('201 Created', b''), (status, body))
|
|
self.assertEqual([
|
|
('HEAD', '/v1/AUTH_test/cont/object'),
|
|
('PUT', '/v1/AUTH_test/c/man?multipart-manifest=put'),
|
|
], self.app.calls)
|
|
# HEAD shouldn't be conditional
|
|
self.assertNotIn('If-None-Match', self.app.headers[0])
|
|
# But the PUT should be
|
|
self.assertIn('If-None-Match', self.app.headers[1])
|
|
self.assertEqual('*', self.app.headers[1]['If-None-Match'])
|
|
|
|
def test_handle_single_ranges(self):
|
|
good_data = json.dumps(
|
|
[{'path': '/checktest/a_1', 'etag': None,
|
|
'size_bytes': None, 'range': '0-0'},
|
|
{'path': '/checktest/b_2', 'etag': None,
|
|
'size_bytes': 2, 'range': '-1'},
|
|
{'path': '/checktest/b_2', 'etag': None,
|
|
'size_bytes': 2, 'range': '0-0'},
|
|
{'path': '/checktest/a_1', 'etag': None,
|
|
'size_bytes': None},
|
|
{'path': '/cont/object', 'etag': None,
|
|
'size_bytes': None, 'range': '10-40'}])
|
|
override_header = 'X-Object-Sysmeta-Container-Update-Override-Etag'
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=put',
|
|
environ={'REQUEST_METHOD': 'PUT'}, body=good_data,
|
|
headers={override_header: 'my custom etag'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(('201 Created', b''), (status, body))
|
|
expected_etag = '"%s"' % md5hex(
|
|
'ab:1-1;b:0-0;aetagoftheobjectsegment:10-40;')
|
|
self.assertEqual(expected_etag, dict(headers)['Etag'])
|
|
self.assertEqual([
|
|
('HEAD', '/v1/AUTH_test/checktest/a_1'), # Only once!
|
|
('HEAD', '/v1/AUTH_test/checktest/b_2'), # Only once!
|
|
('HEAD', '/v1/AUTH_test/cont/object'),
|
|
], sorted(self.app.calls[:-1]))
|
|
self.assertEqual(
|
|
('PUT', '/v1/AUTH_test/checktest/man_3?multipart-manifest=put'),
|
|
self.app.calls[-1])
|
|
self.assertEqual(
|
|
'my custom etag; slo_etag=%s' % expected_etag.strip('"'),
|
|
self.app.headers[-1].get(override_header))
|
|
|
|
# Check that we still populated the manifest properly from our HEADs
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/checktest/man_3?multipart-manifest=get',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_app(req)
|
|
manifest_data = json.loads(body)
|
|
self.assertEqual(len(manifest_data), 5)
|
|
|
|
self.assertEqual('a', manifest_data[0]['hash'])
|
|
self.assertNotIn('range', manifest_data[0])
|
|
|
|
self.assertEqual('b', manifest_data[1]['hash'])
|
|
self.assertEqual('1-1', manifest_data[1]['range'])
|
|
|
|
self.assertEqual('b', manifest_data[2]['hash'])
|
|
self.assertEqual('0-0', manifest_data[2]['range'])
|
|
|
|
self.assertEqual('a', manifest_data[3]['hash'])
|
|
self.assertNotIn('range', manifest_data[3])
|
|
|
|
self.assertEqual('etagoftheobjectsegment', manifest_data[4]['hash'])
|
|
self.assertEqual('10-40', manifest_data[4]['range'])
|
|
|
|
|
|
class TestSloDeleteManifest(SloTestCase):
|
|
|
|
def setUp(self):
|
|
super(TestSloDeleteManifest, self).setUp()
|
|
|
|
_submanifest_data = json.dumps(
|
|
[{'name': '/deltest/b_2', 'hash': 'a', 'bytes': '1'},
|
|
{'name': '/deltest/c_3', 'hash': 'b', 'bytes': '2'}])
|
|
_submanifest_data = _submanifest_data.encode('ascii')
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/man_404',
|
|
swob.HTTPNotFound, {}, None)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/man',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/deltest/gone', 'hash': 'a', 'bytes': '1'},
|
|
{'name': '/deltest/b_2', 'hash': 'b', 'bytes': '2'}]).
|
|
encode('ascii'))
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/man',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/man-all-there',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/deltest/b_2', 'hash': 'a', 'bytes': '1'},
|
|
{'name': '/deltest/c_3', 'hash': 'b', 'bytes': '2'}]).
|
|
encode('ascii'))
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test-un\xc3\xafcode',
|
|
swob.HTTPOk, {}, None)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test-un\xc3\xafcode/deltest', swob.HTTPOk, {
|
|
'X-Container-Read': 'diff read',
|
|
'X-Container-Write': 'diff write',
|
|
}, None)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test-un\xc3\xafcode/\xe2\x98\x83', swob.HTTPOk, {
|
|
'X-Container-Read': 'same read',
|
|
'X-Container-Write': 'same write',
|
|
}, None)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test-un\xc3\xafcode/deltest/man-all-there',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([
|
|
{'name': u'/\N{SNOWMAN}/b_2', 'hash': 'a', 'bytes': '1'},
|
|
{'name': u'/\N{SNOWMAN}/c_3', 'hash': 'b', 'bytes': '2'},
|
|
]).encode('ascii'))
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test-un\xc3\xafcode/\xe2\x98\x83/same-container',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([
|
|
{'name': u'/\N{SNOWMAN}/b_2', 'hash': 'a', 'bytes': '1'},
|
|
{'name': u'/\N{SNOWMAN}/c_3', 'hash': 'b', 'bytes': '2'},
|
|
]).encode('ascii'))
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/man-all-there',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/gone',
|
|
swob.HTTPNotFound, {}, None)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/a_1',
|
|
swob.HTTPOk, {'Content-Length': '1'}, 'a')
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/a_1',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/b_2',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/c_3',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/d_3',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test-un\xc3\xafcode/deltest/man-all-there',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE',
|
|
'/v1/AUTH_test-un\xc3\xafcode/\xe2\x98\x83/same-container',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test-un\xc3\xafcode/\xe2\x98\x83/b_2',
|
|
swob.HTTPNoContent, {}, None)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test-un\xc3\xafcode/\xe2\x98\x83/c_3',
|
|
swob.HTTPNoContent, {}, None)
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/manifest-with-submanifest',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/deltest/a_1',
|
|
'hash': 'a', 'bytes': '1'},
|
|
{'name': '/deltest/submanifest', 'sub_slo': True,
|
|
'hash': 'submanifest-etag',
|
|
'bytes': len(_submanifest_data)},
|
|
{'name': '/deltest/d_3',
|
|
'hash': 'd', 'bytes': '3'}]).encode('ascii'))
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/manifest-with-submanifest',
|
|
swob.HTTPNoContent, {}, None)
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/submanifest',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
_submanifest_data)
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/submanifest',
|
|
swob.HTTPNoContent, {}, None)
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/manifest-missing-submanifest',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/deltest/a_1', 'hash': 'a', 'bytes': '1'},
|
|
{'name': '/deltest/missing-submanifest',
|
|
'hash': 'a', 'bytes': '2', 'sub_slo': True},
|
|
{'name': '/deltest/d_3', 'hash': 'd', 'bytes': '3'}]).
|
|
encode('ascii'))
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/manifest-missing-submanifest',
|
|
swob.HTTPNoContent, {}, None)
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/missing-submanifest',
|
|
swob.HTTPNotFound, {}, None)
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/manifest-badjson',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
b"[not {json (at ++++all")
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/manifest-with-unauth-segment',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/deltest/a_1', 'hash': 'a', 'bytes': '1'},
|
|
{'name': '/deltest-unauth/q_17',
|
|
'hash': '11', 'bytes': '17'}]).encode('ascii'))
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest/manifest-with-unauth-segment',
|
|
swob.HTTPNoContent, {}, None)
|
|
|
|
self.app.register(
|
|
'DELETE', '/v1/AUTH_test/deltest-unauth/q_17',
|
|
swob.HTTPUnauthorized, {}, None)
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/manifest-with-too-many-segs',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/deltest/a_1',
|
|
'hash': 'a', 'bytes': '1'},
|
|
{'name': '/deltest/multi-submanifest', 'sub_slo': True,
|
|
'hash': 'submanifest-etag',
|
|
'bytes': len(_submanifest_data)},
|
|
{'name': '/deltest/b_2',
|
|
'hash': 'b', 'bytes': '1'},
|
|
{'name': '/deltest/c_3',
|
|
'hash': 'c', 'bytes': '1'},
|
|
{'name': '/deltest/d_4',
|
|
'hash': 'b', 'bytes': '1'},
|
|
{'name': '/deltest/e_5',
|
|
'hash': 'c', 'bytes': '1'},
|
|
{'name': '/deltest/f_6',
|
|
'hash': 'b', 'bytes': '1'},
|
|
{'name': '/deltest/g_8',
|
|
'hash': 'c', 'bytes': '1'},
|
|
{'name': '/deltest/g_8',
|
|
'hash': 'c', 'bytes': '1'},
|
|
{'name': '/deltest/h_9',
|
|
'hash': 'd', 'bytes': '3'}]))
|
|
|
|
def test_handle_multipart_delete_man(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man',
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
self.slo(req.environ, fake_start_response)
|
|
self.assertEqual(self.app.call_count, 1)
|
|
|
|
def test_handle_multipart_delete_bad_utf8(self):
|
|
req = Request.blank(
|
|
b'/v1/AUTH_test/deltest/man\xff\xfe?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(resp_data['Response Status'],
|
|
'412 Precondition Failed')
|
|
|
|
def test_handle_multipart_delete_whole_404(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man_404?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET',
|
|
'/v1/AUTH_test/deltest/man_404?multipart-manifest=get')])
|
|
self.assertEqual(resp_data['Response Status'], '200 OK')
|
|
self.assertEqual(resp_data['Response Body'], '')
|
|
self.assertEqual(resp_data['Number Deleted'], 0)
|
|
self.assertEqual(resp_data['Number Not Found'], 1)
|
|
self.assertEqual(resp_data['Errors'], [])
|
|
|
|
def test_handle_multipart_delete_segment_404(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(
|
|
set(self.app.calls),
|
|
set([('GET',
|
|
'/v1/AUTH_test/deltest/man?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/gone'),
|
|
('DELETE', '/v1/AUTH_test/deltest/b_2'),
|
|
('DELETE', '/v1/AUTH_test/deltest/man')]))
|
|
self.assertEqual(resp_data['Response Status'], '200 OK')
|
|
self.assertEqual(resp_data['Number Deleted'], 2)
|
|
self.assertEqual(resp_data['Number Not Found'], 1)
|
|
|
|
def test_handle_multipart_delete_whole(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
self.call_slo(req)
|
|
self.assertEqual(set(self.app.calls), set([
|
|
('GET',
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/b_2'),
|
|
('DELETE', '/v1/AUTH_test/deltest/c_3'),
|
|
('DELETE', ('/v1/AUTH_test/deltest/man-all-there'))]))
|
|
|
|
def test_handle_multipart_delete_whole_old_swift(self):
|
|
# behave like pre-2.24.0 swift; initial GET will return just one byte
|
|
self.app.can_ignore_range = False
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
self.call_slo(req)
|
|
self.assertEqual(self.app.calls_with_headers[:2], [
|
|
('GET',
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=get',
|
|
{'Host': 'localhost:80',
|
|
'User-Agent': 'Mozzarella Foxfire MultipartDELETE',
|
|
'Range': 'bytes=-1',
|
|
'X-Backend-Ignore-Range-If-Metadata-Present':
|
|
'X-Static-Large-Object',
|
|
'Content-Length': '0'}),
|
|
('GET',
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=get',
|
|
{'Host': 'localhost:80',
|
|
'User-Agent': 'Mozzarella Foxfire MultipartDELETE',
|
|
'Content-Length': '0'}),
|
|
])
|
|
self.assertEqual(set(self.app.calls), set([
|
|
('GET',
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/b_2'),
|
|
('DELETE', '/v1/AUTH_test/deltest/c_3'),
|
|
('DELETE', ('/v1/AUTH_test/deltest/man-all-there'))]))
|
|
|
|
def test_handle_multipart_delete_non_ascii(self):
|
|
unicode_acct = u'AUTH_test-un\u00efcode'
|
|
wsgi_acct = bytes_to_wsgi(unicode_acct.encode('utf-8'))
|
|
req = Request.blank(
|
|
'/v1/%s/deltest/man-all-there?'
|
|
'multipart-manifest=delete' % wsgi_acct,
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
status, _, body = self.call_slo(req)
|
|
self.assertEqual('200 OK', status)
|
|
lines = body.split(b'\n')
|
|
for l in lines:
|
|
parts = l.split(b':')
|
|
if len(parts) == 1:
|
|
continue
|
|
key, value = parts
|
|
if key == 'Response Status':
|
|
delete_status = int(value.split()[0])
|
|
self.assertEqual(200, delete_status)
|
|
|
|
self.assertEqual(set(self.app.calls), set([
|
|
('GET',
|
|
'/v1/%s/deltest/man-all-there'
|
|
'?multipart-manifest=get' % wsgi_acct),
|
|
('DELETE', '/v1/%s/\xe2\x98\x83/b_2' % wsgi_acct),
|
|
('DELETE', '/v1/%s/\xe2\x98\x83/c_3' % wsgi_acct),
|
|
('DELETE', ('/v1/%s/deltest/man-all-there' % wsgi_acct))]))
|
|
|
|
def test_handle_multipart_delete_nested(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/manifest-with-submanifest?' +
|
|
'multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
self.call_slo(req)
|
|
self.assertEqual(
|
|
set(self.app.calls),
|
|
{('GET', '/v1/AUTH_test/deltest/' +
|
|
'manifest-with-submanifest?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/deltest/' +
|
|
'submanifest?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/a_1'),
|
|
('DELETE', '/v1/AUTH_test/deltest/b_2'),
|
|
('DELETE', '/v1/AUTH_test/deltest/c_3'),
|
|
('DELETE', '/v1/AUTH_test/deltest/submanifest'),
|
|
('DELETE', '/v1/AUTH_test/deltest/d_3'),
|
|
('DELETE', '/v1/AUTH_test/deltest/manifest-with-submanifest')})
|
|
|
|
def test_handle_multipart_delete_nested_too_many_segments(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/manifest-with-too-many-segs?' +
|
|
'multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
with patch.object(self.slo, 'max_manifest_segments', 1):
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(resp_data['Response Status'], '400 Bad Request')
|
|
self.assertEqual(resp_data['Response Body'],
|
|
'Too many buffered slo segments to delete.')
|
|
|
|
def test_handle_multipart_delete_nested_404(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/manifest-missing-submanifest' +
|
|
'?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(set(self.app.calls), {
|
|
('GET', '/v1/AUTH_test/deltest/' +
|
|
'manifest-missing-submanifest?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/a_1'),
|
|
('GET', '/v1/AUTH_test/deltest/' +
|
|
'missing-submanifest?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/d_3'),
|
|
('DELETE', '/v1/AUTH_test/deltest/manifest-missing-submanifest'),
|
|
})
|
|
self.assertEqual(resp_data['Response Status'], '200 OK')
|
|
self.assertEqual(resp_data['Response Body'], '')
|
|
self.assertEqual(resp_data['Number Deleted'], 3)
|
|
self.assertEqual(resp_data['Number Not Found'], 1)
|
|
self.assertEqual(resp_data['Errors'], [])
|
|
|
|
def test_handle_multipart_delete_nested_401(self):
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/submanifest',
|
|
swob.HTTPUnauthorized, {}, None)
|
|
|
|
req = Request.blank(
|
|
('/v1/AUTH_test/deltest/manifest-with-submanifest' +
|
|
'?multipart-manifest=delete'),
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(resp_data['Response Status'], '400 Bad Request')
|
|
self.assertEqual(resp_data['Errors'],
|
|
[['/deltest/submanifest', '401 Unauthorized']])
|
|
|
|
def test_handle_multipart_delete_nested_500(self):
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/submanifest',
|
|
swob.HTTPServerError, {}, None)
|
|
|
|
req = Request.blank(
|
|
('/v1/AUTH_test/deltest/manifest-with-submanifest' +
|
|
'?multipart-manifest=delete'),
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(resp_data['Response Status'], '400 Bad Request')
|
|
self.assertEqual(resp_data['Errors'],
|
|
[['/deltest/submanifest',
|
|
'Unable to load SLO manifest or segment.']])
|
|
|
|
def test_handle_multipart_delete_not_a_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/a_1?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/deltest/a_1?multipart-manifest=get')])
|
|
self.assertEqual(resp_data['Response Status'], '400 Bad Request')
|
|
self.assertEqual(resp_data['Response Body'], '')
|
|
self.assertEqual(resp_data['Number Deleted'], 0)
|
|
self.assertEqual(resp_data['Number Not Found'], 0)
|
|
self.assertEqual(resp_data['Errors'],
|
|
[['/deltest/a_1', 'Not an SLO manifest']])
|
|
self.assertFalse(self.app.unread_requests)
|
|
|
|
def test_handle_multipart_delete_bad_json(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/manifest-badjson?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(self.app.calls,
|
|
[('GET', '/v1/AUTH_test/deltest/' +
|
|
'manifest-badjson?multipart-manifest=get')])
|
|
self.assertEqual(resp_data['Response Status'], '400 Bad Request')
|
|
self.assertEqual(resp_data['Response Body'], '')
|
|
self.assertEqual(resp_data['Number Deleted'], 0)
|
|
self.assertEqual(resp_data['Number Not Found'], 0)
|
|
self.assertEqual(resp_data['Errors'],
|
|
[['/deltest/manifest-badjson',
|
|
'Unable to load SLO manifest']])
|
|
|
|
def test_handle_multipart_delete_401(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/manifest-with-unauth-segment' +
|
|
'?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(
|
|
set(self.app.calls),
|
|
set([('GET', '/v1/AUTH_test/deltest/' +
|
|
'manifest-with-unauth-segment?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/a_1'),
|
|
('DELETE', '/v1/AUTH_test/deltest-unauth/q_17'),
|
|
('DELETE', '/v1/AUTH_test/deltest/' +
|
|
'manifest-with-unauth-segment')]))
|
|
self.assertEqual(resp_data['Response Status'], '400 Bad Request')
|
|
self.assertEqual(resp_data['Response Body'], '')
|
|
self.assertEqual(resp_data['Number Deleted'], 2)
|
|
self.assertEqual(resp_data['Number Not Found'], 0)
|
|
self.assertEqual(resp_data['Errors'],
|
|
[['/deltest-unauth/q_17', '401 Unauthorized']])
|
|
|
|
def test_handle_multipart_delete_client_content_type(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE', 'CONTENT_TYPE': 'foo/bar'},
|
|
headers={'Accept': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(resp_data["Number Deleted"], 3)
|
|
|
|
self.assertEqual(set(self.app.calls), set([
|
|
('GET',
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/b_2'),
|
|
('DELETE', '/v1/AUTH_test/deltest/c_3'),
|
|
('DELETE', '/v1/AUTH_test/deltest/man-all-there')]))
|
|
|
|
def test_handle_async_delete_whole_404(self):
|
|
self.slo.allow_async_delete = True
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man_404?async=t&multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('404 Not Found', status)
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET',
|
|
'/v1/AUTH_test/deltest/man_404?multipart-manifest=get')])
|
|
|
|
def test_handle_async_delete_turned_off(self):
|
|
self.slo.allow_async_delete = False
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man-all-there?'
|
|
'multipart-manifest=delete&async=on&heartbeat=on',
|
|
environ={'REQUEST_METHOD': 'DELETE'},
|
|
headers={'Accept': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
resp_data = json.loads(body)
|
|
self.assertEqual(resp_data["Number Deleted"], 3)
|
|
|
|
self.assertEqual(set(self.app.calls), set([
|
|
('GET',
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=get'),
|
|
('DELETE', '/v1/AUTH_test/deltest/b_2'),
|
|
('DELETE', '/v1/AUTH_test/deltest/c_3'),
|
|
('DELETE', '/v1/AUTH_test/deltest/man-all-there')]))
|
|
|
|
def test_handle_async_delete_whole(self):
|
|
self.slo.allow_async_delete = True
|
|
now = Timestamp(time.time())
|
|
exp_obj_cont = get_expirer_container(
|
|
int(now), 86400, 'AUTH_test', 'deltest', 'man-all-there')
|
|
self.app.register(
|
|
'UPDATE', '/v1/.expiring_objects/%s' % exp_obj_cont,
|
|
swob.HTTPNoContent, {}, None)
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man-all-there'
|
|
'?async=true&multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
with patch('swift.common.utils.Timestamp.now', return_value=now):
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual('204 No Content', status)
|
|
self.assertEqual(b'', body)
|
|
self.assertEqual(self.app.calls, [
|
|
('GET',
|
|
'/v1/AUTH_test/deltest/man-all-there?multipart-manifest=get'),
|
|
('UPDATE', '/v1/.expiring_objects/%s'
|
|
'?async=true&multipart-manifest=delete' % exp_obj_cont),
|
|
('DELETE', '/v1/AUTH_test/deltest/man-all-there'
|
|
'?async=true&multipart-manifest=delete'),
|
|
])
|
|
|
|
for header, expected in (
|
|
('Content-Type', 'application/json'),
|
|
('X-Backend-Storage-Policy-Index', '0'),
|
|
('X-Backend-Allow-Private-Methods', 'True'),
|
|
):
|
|
self.assertIn(header, self.app.calls_with_headers[1].headers)
|
|
value = self.app.calls_with_headers[1].headers[header]
|
|
msg = 'Expected %s header to be %r, not %r'
|
|
self.assertEqual(value, expected, msg % (header, expected, value))
|
|
|
|
self.assertEqual(json.loads(self.app.req_bodies[1]), [
|
|
{'content_type': 'application/async-deleted',
|
|
'created_at': now.internal,
|
|
'deleted': 0,
|
|
'etag': 'd41d8cd98f00b204e9800998ecf8427e',
|
|
'name': '%s-AUTH_test/deltest/b_2' % now.internal,
|
|
'size': 0,
|
|
'storage_policy_index': 0},
|
|
{'content_type': 'application/async-deleted',
|
|
'created_at': now.internal,
|
|
'deleted': 0,
|
|
'etag': 'd41d8cd98f00b204e9800998ecf8427e',
|
|
'name': '%s-AUTH_test/deltest/c_3' % now.internal,
|
|
'size': 0,
|
|
'storage_policy_index': 0},
|
|
])
|
|
|
|
def test_handle_async_delete_non_ascii(self):
|
|
self.slo.allow_async_delete = True
|
|
unicode_acct = u'AUTH_test-un\u00efcode'
|
|
wsgi_acct = bytes_to_wsgi(unicode_acct.encode('utf-8'))
|
|
now = Timestamp(time.time())
|
|
exp_obj_cont = get_expirer_container(
|
|
int(now), 86400, unicode_acct, 'deltest', 'man-all-there')
|
|
self.app.register(
|
|
'UPDATE', '/v1/.expiring_objects/%s' % exp_obj_cont,
|
|
swob.HTTPNoContent, {}, None)
|
|
authorize_calls = []
|
|
|
|
def authorize(req):
|
|
authorize_calls.append((req.method, req.acl))
|
|
|
|
req = Request.blank(
|
|
'/v1/%s/deltest/man-all-there?'
|
|
'async=1&multipart-manifest=delete&heartbeat=1' % wsgi_acct,
|
|
environ={'REQUEST_METHOD': 'DELETE', 'swift.authorize': authorize})
|
|
with patch('swift.common.utils.Timestamp.now', return_value=now):
|
|
status, _, body = self.call_slo(req)
|
|
# Every async delete should only need to make 3 requests during the
|
|
# client request/response cycle, so no need to support heart-beating
|
|
self.assertEqual('204 No Content', status)
|
|
self.assertEqual(b'', body)
|
|
|
|
self.assertEqual(self.app.calls, [
|
|
('GET',
|
|
'/v1/%s/deltest/man-all-there?'
|
|
'multipart-manifest=get' % wsgi_acct),
|
|
('HEAD', '/v1/%s' % wsgi_acct),
|
|
('HEAD', '/v1/%s/deltest' % wsgi_acct),
|
|
('HEAD', '/v1/%s/\xe2\x98\x83' % wsgi_acct),
|
|
('UPDATE',
|
|
'/v1/.expiring_objects/%s'
|
|
'?async=1&heartbeat=1&multipart-manifest=delete' % exp_obj_cont),
|
|
('DELETE',
|
|
'/v1/%s/deltest/man-all-there'
|
|
'?async=1&heartbeat=1&multipart-manifest=delete' % wsgi_acct),
|
|
])
|
|
self.assertEqual(authorize_calls, [
|
|
('GET', None), # Original GET
|
|
('DELETE', 'diff write'),
|
|
('DELETE', 'same write'),
|
|
('DELETE', None), # Final DELETE
|
|
])
|
|
|
|
for header, expected in (
|
|
('Content-Type', 'application/json'),
|
|
('X-Backend-Storage-Policy-Index', '0'),
|
|
('X-Backend-Allow-Private-Methods', 'True'),
|
|
):
|
|
self.assertIn(header, self.app.calls_with_headers[-2].headers)
|
|
value = self.app.calls_with_headers[-2].headers[header]
|
|
msg = 'Expected %s header to be %r, not %r'
|
|
self.assertEqual(value, expected, msg % (header, expected, value))
|
|
|
|
self.assertEqual(json.loads(self.app.req_bodies[-2]), [
|
|
{'content_type': 'application/async-deleted',
|
|
'created_at': now.internal,
|
|
'deleted': 0,
|
|
'etag': 'd41d8cd98f00b204e9800998ecf8427e',
|
|
'name': u'%s-%s/\N{SNOWMAN}/b_2' % (now.internal, unicode_acct),
|
|
'size': 0,
|
|
'storage_policy_index': 0},
|
|
{'content_type': 'application/async-deleted',
|
|
'created_at': now.internal,
|
|
'deleted': 0,
|
|
'etag': 'd41d8cd98f00b204e9800998ecf8427e',
|
|
'name': u'%s-%s/\N{SNOWMAN}/c_3' % (now.internal, unicode_acct),
|
|
'size': 0,
|
|
'storage_policy_index': 0},
|
|
])
|
|
|
|
def test_handle_async_delete_non_ascii_same_container(self):
|
|
self.slo.allow_async_delete = True
|
|
unicode_acct = u'AUTH_test-un\u00efcode'
|
|
wsgi_acct = bytes_to_wsgi(unicode_acct.encode('utf-8'))
|
|
now = Timestamp(time.time())
|
|
exp_obj_cont = get_expirer_container(
|
|
int(now), 86400, unicode_acct, u'\N{SNOWMAN}', 'same-container')
|
|
self.app.register(
|
|
'UPDATE', '/v1/.expiring_objects/%s' % exp_obj_cont,
|
|
swob.HTTPNoContent, {}, None)
|
|
authorize_calls = []
|
|
|
|
def authorize(req):
|
|
authorize_calls.append((req.method, req.acl))
|
|
|
|
req = Request.blank(
|
|
'/v1/%s/\xe2\x98\x83/same-container?'
|
|
'async=yes&multipart-manifest=delete' % wsgi_acct,
|
|
environ={'REQUEST_METHOD': 'DELETE', 'swift.authorize': authorize})
|
|
with patch('swift.common.utils.Timestamp.now', return_value=now):
|
|
status, _, body = self.call_slo(req)
|
|
self.assertEqual('204 No Content', status)
|
|
self.assertEqual(b'', body)
|
|
|
|
self.assertEqual(self.app.calls, [
|
|
('GET',
|
|
'/v1/%s/\xe2\x98\x83/same-container?'
|
|
'multipart-manifest=get' % wsgi_acct),
|
|
('HEAD', '/v1/%s' % wsgi_acct),
|
|
('HEAD', '/v1/%s/\xe2\x98\x83' % wsgi_acct),
|
|
('UPDATE',
|
|
'/v1/.expiring_objects/%s'
|
|
'?async=yes&multipart-manifest=delete' % exp_obj_cont),
|
|
('DELETE',
|
|
'/v1/%s/\xe2\x98\x83/same-container'
|
|
'?async=yes&multipart-manifest=delete' % wsgi_acct),
|
|
])
|
|
self.assertEqual(authorize_calls, [
|
|
('GET', None), # Original GET
|
|
('DELETE', 'same write'), # Only need one auth check
|
|
('DELETE', None), # Final DELETE
|
|
])
|
|
|
|
for header, expected in (
|
|
('Content-Type', 'application/json'),
|
|
('X-Backend-Storage-Policy-Index', '0'),
|
|
('X-Backend-Allow-Private-Methods', 'True'),
|
|
):
|
|
self.assertIn(header, self.app.calls_with_headers[-2].headers)
|
|
value = self.app.calls_with_headers[-2].headers[header]
|
|
msg = 'Expected %s header to be %r, not %r'
|
|
self.assertEqual(value, expected, msg % (header, expected, value))
|
|
|
|
self.assertEqual(json.loads(self.app.req_bodies[-2]), [
|
|
{'content_type': 'application/async-deleted',
|
|
'created_at': now.internal,
|
|
'deleted': 0,
|
|
'etag': 'd41d8cd98f00b204e9800998ecf8427e',
|
|
'name': u'%s-%s/\N{SNOWMAN}/b_2' % (now.internal, unicode_acct),
|
|
'size': 0,
|
|
'storage_policy_index': 0},
|
|
{'content_type': 'application/async-deleted',
|
|
'created_at': now.internal,
|
|
'deleted': 0,
|
|
'etag': 'd41d8cd98f00b204e9800998ecf8427e',
|
|
'name': u'%s-%s/\N{SNOWMAN}/c_3' % (now.internal, unicode_acct),
|
|
'size': 0,
|
|
'storage_policy_index': 0},
|
|
])
|
|
|
|
def test_handle_async_delete_nested(self):
|
|
self.slo.allow_async_delete = True
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/manifest-with-submanifest' +
|
|
'?async=on&multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
status, _, body = self.call_slo(req)
|
|
self.assertEqual('400 Bad Request', status)
|
|
self.assertEqual(b'No segments may be large objects.', body)
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/deltest/' +
|
|
'manifest-with-submanifest?multipart-manifest=get')])
|
|
|
|
def test_handle_async_delete_too_many_containers(self):
|
|
self.slo.allow_async_delete = True
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/deltest/man',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/cont1/a_1', 'hash': 'a', 'bytes': '1'},
|
|
{'name': '/cont2/b_2', 'hash': 'b', 'bytes': '2'}]).
|
|
encode('ascii'))
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/deltest/man?async=on&multipart-manifest=delete',
|
|
environ={'REQUEST_METHOD': 'DELETE'})
|
|
status, _, body = self.call_slo(req)
|
|
self.assertEqual('400 Bad Request', status)
|
|
expected = b'All segments must be in one container. Found segments in '
|
|
self.assertEqual(expected, body[:len(expected)])
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/deltest/man?multipart-manifest=get')])
|
|
|
|
|
|
class SloGETorHEADTestCase(SloTestCase):
|
|
"""
|
|
Any GET or HEAD test-case should exercise legacy manifests written before
|
|
we added etag/size SLO Sysmeta.
|
|
|
|
N.B. We used to GET the whole manifest to calculate etag/size, just to
|
|
respond to HEAD requests.
|
|
"""
|
|
|
|
modern_manifest_headers = True
|
|
|
|
def maybe_add_modern_manifest_headers(self, headers, manifest):
|
|
hasher = md5(usedforsecurity=False)
|
|
calculated_size = 0
|
|
for seg_dict in manifest:
|
|
if 'data' in seg_dict:
|
|
raw_data = base64.b64decode(seg_dict['data'])
|
|
segment_etag = md5(raw_data, usedforsecurity=False).hexdigest()
|
|
segment_length = len(raw_data)
|
|
else:
|
|
segment_etag = seg_dict['hash']
|
|
if 'range' in seg_dict:
|
|
segment_etag += ':%s;' % seg_dict['range']
|
|
start, end = seg_dict['range'].split('-')
|
|
segment_length = int(end) - int(start) + 1
|
|
else:
|
|
segment_length = int(seg_dict['bytes'])
|
|
calculated_size += segment_length
|
|
hasher.update(segment_etag.encode())
|
|
calculated_etag = hasher.hexdigest()
|
|
if self.modern_manifest_headers:
|
|
headers.update({
|
|
'X-Object-Sysmeta-Slo-Etag': calculated_etag,
|
|
'X-Object-Sysmeta-Slo-Size': calculated_size,
|
|
})
|
|
return calculated_etag, calculated_size
|
|
|
|
def setUp(self):
|
|
super(SloGETorHEADTestCase, self).setUp()
|
|
self.expected_unread_requests = {}
|
|
|
|
def tearDown(self):
|
|
# SloTestCase always has an app
|
|
self.assertEqual(self.app.unclosed_requests, {})
|
|
self.assertEqual(self.app.unread_requests,
|
|
self.expected_unread_requests)
|
|
|
|
def call_slo(self, req):
|
|
# all the tests that inhert from this class were part of a major test
|
|
# refactor in an effort to normalize and strengthen assertions; in
|
|
# general it would probably be reasonable for call_app to return a
|
|
# HeaderKeyDict but at the time was considered unrelated to the
|
|
# GETorHEAD TestCase refactor
|
|
status, raw_headers, body = super(
|
|
SloGETorHEADTestCase, self).call_slo(req)
|
|
headers = HeaderKeyDict(raw_headers)
|
|
self.assertEqual(
|
|
len(raw_headers), len(headers),
|
|
'Got duplicate names in response headers: %r' % raw_headers)
|
|
return status, headers, body
|
|
|
|
def _setup_alphabet_objects(self, letters, container='gettest'):
|
|
"""
|
|
A common convention in these tests uses segments named a_5, b_10, etc.
|
|
|
|
They're nothing special, just small regular objects with names that
|
|
describe their content and size.
|
|
"""
|
|
for i, letter in enumerate(string.ascii_lowercase):
|
|
if letter not in letters:
|
|
continue
|
|
size = (i + 1) * 5
|
|
body = letter * size
|
|
path = '/v1/AUTH_test/%s/%s_%s' % (container, letter, size)
|
|
self.app.register('GET', path, swob.HTTPOk, {
|
|
'Content-Length': len(body),
|
|
'Etag': md5hex(body),
|
|
}, body)
|
|
|
|
def _setup_manifest(self, name, manifest, extra_headers=None,
|
|
attr_key=None, container='c', obj_key=None):
|
|
"""
|
|
This helper should be used to create manifests that have descriptive
|
|
names you can reference in tests. Think of manifests as having
|
|
"personalities" - you should get to known them over the course of a few
|
|
test methods; you can share them between TestCases. When you want to
|
|
test something on a SLO with a slightly different manifest resist the
|
|
temptation to update an existing manifest; just create a new
|
|
personality - and give it a name that recognizes its heritage and
|
|
follows existing patterns - don't worry we won't run out of memory
|
|
having too many similar but different manifests setup in our tests.
|
|
"""
|
|
if not attr_key:
|
|
# _setup_manifest attrs will *always* be prefixed with manifest,
|
|
# but you can override the glob in the form manifest_*_<attr>
|
|
attr_key = name.replace('-', '_').lower()
|
|
if obj_key is None:
|
|
# seems reasonable when reading tests to see request paths that
|
|
# clearly specify the object is a manifest; so this is a strong
|
|
# convention. But if we ever have a bug we can only repro if the
|
|
# manifest doesn't start a literal "manifest-" you can register the
|
|
# object name however you want.
|
|
obj_key = 'manifest-%s' % name
|
|
manifest_json = json.dumps(manifest)
|
|
setattr(self, 'manifest_%s_json_size' % attr_key, len(manifest_json))
|
|
json_md5 = md5hex(manifest_json)
|
|
setattr(self, 'manifest_%s_json_md5' % attr_key, json_md5)
|
|
manifest_headers = {
|
|
'Content-Length': str(len(manifest_json)),
|
|
'X-Static-Large-Object': 'true',
|
|
'Etag': json_md5,
|
|
# In my testing it's not possible to create an SLO manifest that
|
|
# has *no* content-type, both empty or missing Content-Type header
|
|
# on ?multipart-manifest=put result in a default
|
|
# "application/octet-stream" value being stored in the manifest
|
|
# metadata; still I wouldn't assert on this value in these tests,
|
|
# you may not be testing what you think you are - N.B. some tests
|
|
# will override this value with the "extra_headers" param.
|
|
'Content-Type': 'application/octet-stream',
|
|
}
|
|
if extra_headers is not None:
|
|
manifest_headers.update(extra_headers)
|
|
slo_etag, slo_size = self.maybe_add_modern_manifest_headers(
|
|
manifest_headers, manifest)
|
|
setattr(self, 'manifest_%s_slo_etag' % attr_key, slo_etag)
|
|
setattr(self, 'manifest_%s_slo_size' % attr_key, slo_size)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/%s/%s' % (container, obj_key),
|
|
swob.HTTPOk, manifest_headers, manifest_json.encode('ascii'))
|
|
|
|
def _setup_manifest_single_segment(self):
|
|
"""
|
|
This manifest's segments are all regular objects.
|
|
"""
|
|
_single_segment_manifest = [
|
|
{'name': '/gettest/b_50', 'hash': md5hex('b' * 50), 'bytes': '50',
|
|
'content_type': 'text/plain'},
|
|
]
|
|
self._setup_manifest(
|
|
'single-segment', _single_segment_manifest,
|
|
extra_headers={'X-Object-Meta-Nature': 'Regular'},
|
|
container='gettest')
|
|
|
|
def _setup_manifest_data(self):
|
|
_data_manifest = [
|
|
{
|
|
'data': base64.b64encode(b'123456').decode('ascii')
|
|
}, {
|
|
'name': '/gettest/a_5',
|
|
'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain',
|
|
'bytes': '5',
|
|
}, {
|
|
'data': base64.b64encode(b'ABCDEF').decode('ascii')
|
|
},
|
|
]
|
|
self._setup_manifest('data', _data_manifest)
|
|
|
|
def _setup_manifest_bc(self):
|
|
"""
|
|
This manifest's segments are all regular objects.
|
|
"""
|
|
_bc_manifest = [
|
|
{'name': '/gettest/b_10', 'hash': md5hex('b' * 10), 'bytes': '10',
|
|
'content_type': 'text/plain'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15), 'bytes': '15',
|
|
'content_type': 'text/plain'}
|
|
]
|
|
self._setup_manifest('bc', _bc_manifest, extra_headers={
|
|
# maybe manifest-bc is about some botony research!?
|
|
'X-Object-Meta-Plant': 'Ficus',
|
|
}, container='gettest')
|
|
|
|
def _setup_manifest_abcd(self):
|
|
"""
|
|
This manifest uses manifest-bc as a sub-manifest!
|
|
"""
|
|
_abcd_manifest = [
|
|
{'name': '/gettest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/manifest-bc', 'sub_slo': True,
|
|
'content_type': 'application/json',
|
|
# N.B. sub-slo-segments use slo-etag & slo-size
|
|
'hash': self.manifest_bc_slo_etag,
|
|
'bytes': self.manifest_bc_slo_size},
|
|
{'name': '/gettest/d_20', 'hash': md5hex("d" * 20),
|
|
'content_type': 'text/plain', 'bytes': '20'},
|
|
]
|
|
self._setup_manifest('abcd', _abcd_manifest, extra_headers={
|
|
# apparently this is a HUGE json object? maybe the plan Ficus data
|
|
# is embeded as base64.
|
|
'Content-Type': 'application/json',
|
|
}, container='gettest')
|
|
|
|
def _setup_manifest_abcdefghijkl(self):
|
|
"""
|
|
Despite the terrible name, this is just a large manifest of regular
|
|
objects.
|
|
"""
|
|
_abcdefghijkl_manifest = [
|
|
{'name': '/gettest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/b_10', 'hash': md5hex("b" * 10),
|
|
'content_type': 'text/plain', 'bytes': '10'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex("c" * 15),
|
|
'content_type': 'text/plain', 'bytes': '15'},
|
|
{'name': '/gettest/d_20', 'hash': md5hex("d" * 20),
|
|
'content_type': 'text/plain', 'bytes': '20'},
|
|
{'name': '/gettest/e_25', 'hash': md5hex("e" * 25),
|
|
'content_type': 'text/plain', 'bytes': '25'},
|
|
{'name': '/gettest/f_30', 'hash': md5hex("f" * 30),
|
|
'content_type': 'text/plain', 'bytes': '30'},
|
|
{'name': '/gettest/g_35', 'hash': md5hex("g" * 35),
|
|
'content_type': 'text/plain', 'bytes': '35'},
|
|
{'name': '/gettest/h_40', 'hash': md5hex("h" * 40),
|
|
'content_type': 'text/plain', 'bytes': '40'},
|
|
{'name': '/gettest/i_45', 'hash': md5hex("i" * 45),
|
|
'content_type': 'text/plain', 'bytes': '45'},
|
|
{'name': '/gettest/j_50', 'hash': md5hex("j" * 50),
|
|
'content_type': 'text/plain', 'bytes': '50'},
|
|
{'name': '/gettest/k_55', 'hash': md5hex("k" * 55),
|
|
'content_type': 'text/plain', 'bytes': '55'},
|
|
{'name': '/gettest/l_60', 'hash': md5hex("l" * 60),
|
|
'content_type': 'text/plain', 'bytes': '60'},
|
|
]
|
|
self._setup_manifest('abcdefghijkl', _abcdefghijkl_manifest,
|
|
extra_headers={}, container='gettest')
|
|
|
|
def _setup_manifest_bc_ranges(self):
|
|
"""
|
|
This manifest's segments are range-segments into regular objects.
|
|
"""
|
|
_bc_ranges_manifest = [
|
|
{'name': '/gettest/b_10', 'hash': md5hex('b' * 10),
|
|
'content_type': 'text/plain', 'bytes': '10',
|
|
'range': '4-7'},
|
|
{'name': '/gettest/b_10', 'hash': md5hex('b' * 10),
|
|
'content_type': 'text/plain', 'bytes': '10',
|
|
'range': '2-5'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15),
|
|
'content_type': 'text/plain', 'bytes': '15',
|
|
'range': '0-3'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15),
|
|
'content_type': 'text/plain', 'bytes': '15',
|
|
'range': '11-14'},
|
|
]
|
|
self._setup_manifest('bc-ranges', _bc_ranges_manifest,
|
|
container='gettest')
|
|
|
|
def _setup_manifest_abcd_ranges(self):
|
|
"""
|
|
This manifest's range-segments use manifest-bc as sub-manifest!
|
|
"""
|
|
_abcd_ranges_manifest = [
|
|
{'name': '/gettest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5',
|
|
'range': '0-3'},
|
|
{'name': '/gettest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5',
|
|
'range': '1-4'},
|
|
{'name': '/gettest/manifest-bc-ranges', 'sub_slo': True,
|
|
'content_type': 'application/json',
|
|
'hash': self.manifest_bc_ranges_slo_etag,
|
|
'bytes': 16,
|
|
'range': '8-15'},
|
|
{'name': '/gettest/manifest-bc-ranges', 'sub_slo': True,
|
|
'content_type': 'application/json',
|
|
'hash': self.manifest_bc_ranges_slo_etag,
|
|
'bytes': self.manifest_bc_ranges_slo_size,
|
|
'range': '0-7'},
|
|
{'name': '/gettest/d_20', 'hash': md5hex("d" * 20),
|
|
'content_type': 'text/plain', 'bytes': '20',
|
|
'range': '0-3'},
|
|
{'name': '/gettest/d_20', 'hash': md5hex("d" * 20),
|
|
'content_type': 'text/plain', 'bytes': '20',
|
|
'range': '8-11'},
|
|
]
|
|
self._setup_manifest(
|
|
'abcd-ranges', _abcd_ranges_manifest, extra_headers={
|
|
# apparently this is another HUGE json object?
|
|
'Content-Type': 'application/json',
|
|
}, container='gettest')
|
|
|
|
def _setup_manifest_abcd_subranges(self):
|
|
"""
|
|
These range-segments use manifest-abcd-ranges as sub-manifests!
|
|
"""
|
|
_abcd_subranges_manifest = [
|
|
{'name': '/gettest/manifest-abcd-ranges', 'sub_slo': True,
|
|
'hash': self.manifest_abcd_ranges_slo_etag,
|
|
'bytes': self.manifest_abcd_ranges_slo_size,
|
|
'content_type': 'text/plain',
|
|
'range': '6-10'},
|
|
{'name': '/gettest/manifest-abcd-ranges', 'sub_slo': True,
|
|
'hash': self.manifest_abcd_ranges_slo_etag,
|
|
'bytes': self.manifest_abcd_ranges_slo_size,
|
|
'content_type': 'text/plain',
|
|
'range': '31-31'},
|
|
{'name': '/gettest/manifest-abcd-ranges', 'sub_slo': True,
|
|
'hash': self.manifest_abcd_ranges_slo_etag,
|
|
'bytes': self.manifest_abcd_ranges_slo_size,
|
|
'content_type': 'text/plain',
|
|
'range': '14-18'},
|
|
{'name': '/gettest/manifest-abcd-ranges', 'sub_slo': True,
|
|
'hash': self.manifest_abcd_ranges_slo_etag,
|
|
'bytes': self.manifest_abcd_ranges_slo_size,
|
|
'content_type': 'text/plain',
|
|
'range': '0-0'},
|
|
{'name': '/gettest/manifest-abcd-ranges', 'sub_slo': True,
|
|
'hash': self.manifest_abcd_ranges_slo_etag,
|
|
'bytes': self.manifest_abcd_ranges_slo_size,
|
|
'content_type': 'text/plain',
|
|
'range': '22-26'},
|
|
]
|
|
self._setup_manifest(
|
|
'abcd-subranges', _abcd_subranges_manifest, extra_headers={
|
|
# apparently this is another HUGE json object?
|
|
'Content-Type': 'application/json',
|
|
}, container='gettest')
|
|
|
|
def _setup_manifest_headtest(self):
|
|
"""
|
|
This is a unqiue manifest, un-related to the linage of gettest with
|
|
different segments. AFAIK the segments responses are never registered.
|
|
It also has it's own weird name.
|
|
"""
|
|
manifest = [
|
|
{'name': '/gettest/seg01',
|
|
'bytes': '100',
|
|
'hash': 'seg01-hash',
|
|
'content_type': 'text/plain',
|
|
'last_modified': '2013-11-19T11:33:45.137446'},
|
|
{'name': '/gettest/seg02',
|
|
'bytes': '200',
|
|
'hash': 'seg02-hash',
|
|
'content_type': 'text/plain',
|
|
'last_modified': '2013-11-19T11:33:45.137447'},
|
|
]
|
|
self._setup_manifest('man', manifest, extra_headers={
|
|
'Content-Type': 'test/data',
|
|
'X-Object-Sysmeta-Artisanal-Etag': 'bespoke',
|
|
}, attr_key='headtest', container='headtest', obj_key='man')
|
|
|
|
def _setup_manifest_aabbccdd(self):
|
|
"""
|
|
This manifest has repeated whole segments, SLO request pattern on a
|
|
manifest like uses multi-range requests to coalesce repated segments
|
|
into a single request!
|
|
"""
|
|
_aabbccdd_manifest = [
|
|
{'name': '/gettest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
|
|
{'name': '/gettest/b_10', 'hash': md5hex("b" * 10),
|
|
'content_type': 'text/plain', 'bytes': '10'},
|
|
{'name': '/gettest/b_10', 'hash': md5hex("b" * 10),
|
|
'content_type': 'text/plain', 'bytes': '10'},
|
|
|
|
{'name': '/gettest/c_15', 'hash': md5hex("c" * 15),
|
|
'content_type': 'text/plain', 'bytes': '15'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex("c" * 15),
|
|
'content_type': 'text/plain', 'bytes': '15'},
|
|
|
|
{'name': '/gettest/d_20', 'hash': md5hex("d" * 20),
|
|
'content_type': 'text/plain', 'bytes': '20'},
|
|
{'name': '/gettest/d_20', 'hash': md5hex("d" * 20),
|
|
'content_type': 'text/plain', 'bytes': '20'}
|
|
]
|
|
self._setup_manifest('aabbccdd', _aabbccdd_manifest,
|
|
container='gettest')
|
|
|
|
|
|
class TestSloHeadOldManifest(SloGETorHEADTestCase):
|
|
|
|
modern_manifest_headers = False
|
|
|
|
def setUp(self):
|
|
super(TestSloHeadOldManifest, self).setUp()
|
|
self._setup_alphabet_objects('abcd')
|
|
self._setup_manifest_bc()
|
|
self._setup_manifest_abcd()
|
|
|
|
self._setup_manifest_headtest()
|
|
# these aliases are not *too* ambiguous, they get a pass
|
|
self.slo_etag = self.manifest_headtest_slo_etag
|
|
self.manifest_json_etag = self.manifest_headtest_json_md5
|
|
|
|
def test_etag_is_hash_of_segment_etags(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/headtest/man',
|
|
environ={'REQUEST_METHOD': 'HEAD'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_json_etag)
|
|
self.assertEqual(headers['Content-Length'], '300')
|
|
self.assertEqual(headers['Content-Type'], 'test/data')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(body, b'') # it's a HEAD request, after all
|
|
|
|
expected_app_calls = [('HEAD', '/v1/AUTH_test/headtest/man')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.append(('GET', '/v1/AUTH_test/headtest/man'))
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_get_manifest_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/headtest/man?multipart-manifest=get',
|
|
environ={'REQUEST_METHOD': 'HEAD'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'], self.manifest_json_etag)
|
|
self.assertEqual(headers['Content-Type'],
|
|
'application/json; charset=utf-8')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Object-Sysmeta-Artisanal-Etag'], 'bespoke')
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_headtest_json_size)
|
|
self.assertEqual(body, b'') # it's a HEAD request, after all
|
|
|
|
expected_app_calls = [(
|
|
'HEAD', '/v1/AUTH_test/headtest/man?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
# this is only relevant for conditional requests; but SLO will only
|
|
# *add* it on SLO requests, not a multipart-manifest=get request
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[0])
|
|
|
|
def test_zero_byte_manifest(self):
|
|
_single_segment_manifest = [
|
|
{'name': '/c/zero', 'hash': md5hex(''), 'bytes': '0',
|
|
'content_type': 'text/plain'},
|
|
]
|
|
self._setup_manifest('zero-byte', _single_segment_manifest)
|
|
req = Request.blank('/v1/AUTH_test/c/manifest-zero-byte',
|
|
method='HEAD')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_zero_byte_slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_zero_byte_json_md5)
|
|
self.assertEqual(body, b'') # it's a HEAD request, after all
|
|
|
|
expected_app_calls = [('HEAD', '/v1/AUTH_test/c/manifest-zero-byte')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.append((
|
|
'GET', '/v1/AUTH_test/c/manifest-zero-byte'))
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_none_match_etag_matching(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/headtest/man',
|
|
environ={'REQUEST_METHOD': 'HEAD'},
|
|
headers={'If-None-Match': self.slo_etag})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '304 Not Modified')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual(headers['Content-Type'], 'test/data')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_json_etag)
|
|
self.assertEqual(headers['X-Object-Sysmeta-Artisanal-Etag'], 'bespoke')
|
|
|
|
expected_app_calls = [('HEAD', '/v1/AUTH_test/headtest/man')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.append(('GET', '/v1/AUTH_test/headtest/man'))
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_match_etag_not_matching(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/headtest/man',
|
|
environ={'REQUEST_METHOD': 'HEAD'},
|
|
headers={'If-Match': 'zzz'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual(headers['Content-Type'], 'test/data')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_json_etag)
|
|
self.assertEqual(headers['X-Object-Sysmeta-Artisanal-Etag'], 'bespoke')
|
|
|
|
expected_app_calls = [('HEAD', '/v1/AUTH_test/headtest/man')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.append(('GET', '/v1/AUTH_test/headtest/man'))
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_none_match_etag_matching_with_override(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/headtest/man',
|
|
environ={'REQUEST_METHOD': 'HEAD'},
|
|
headers={
|
|
'If-None-Match': 'bespoke',
|
|
'X-Backend-Etag-Is-At': 'X-Object-Sysmeta-Artisanal-Etag'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '304 Not Modified')
|
|
# We *are not* responsible for replacing the etag; whoever set
|
|
# x-backend-etag-is-at is responsible
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual(headers['Content-Type'], 'test/data')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_json_etag)
|
|
self.assertEqual(headers['X-Object-Sysmeta-Artisanal-Etag'], 'bespoke')
|
|
|
|
expected_app_calls = [('HEAD', '/v1/AUTH_test/headtest/man')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.append(('GET', '/v1/AUTH_test/headtest/man'))
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_match_etag_not_matching_with_override(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/headtest/man',
|
|
environ={'REQUEST_METHOD': 'HEAD'},
|
|
headers={
|
|
'If-Match': self.slo_etag,
|
|
'X-Backend-Etag-Is-At': 'X-Object-Sysmeta-Artisanal-Etag'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
# We *are not* responsible for replacing the etag; whoever set
|
|
# x-backend-etag-is-at is responsible
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual(headers['Content-Type'], 'test/data')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_json_etag)
|
|
self.assertEqual(headers['X-Object-Sysmeta-Artisanal-Etag'], 'bespoke')
|
|
|
|
expected_app_calls = [('HEAD', '/v1/AUTH_test/headtest/man')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.append(('GET', '/v1/AUTH_test/headtest/man'))
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_head_manifest_is_efficient(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'HEAD'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Length'], '50')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(body, b'')
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
# Note we don't call validate first segment on HEAD. We know the
|
|
# slo size/etag from the manifest, so there's no need for any
|
|
# segment or submanifest fetching here, but a naïve implementation
|
|
# might do it anyway.
|
|
expected_calls.append(
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'))
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
|
|
class TestSloHeadManifest(TestSloHeadOldManifest):
|
|
"""
|
|
Exercise manifests written after we added etag/size SLO Sysmeta
|
|
"""
|
|
|
|
modern_manifest_headers = True
|
|
|
|
|
|
class TestSloGetRawManifest(SloGETorHEADTestCase):
|
|
|
|
modern_manifest_headers = True
|
|
|
|
def setUp(self):
|
|
super(TestSloGetRawManifest, self).setUp()
|
|
self._setup_manifest_raw()
|
|
self._setup_manifest_raw_ranges()
|
|
|
|
def _setup_manifest_raw(self):
|
|
"""
|
|
This is only used by TestSloGetRawManifest; some segments are
|
|
regular objects and one segment is a sub-slo.
|
|
"""
|
|
_raw_manifest = [
|
|
{'name': '/gettest/does_not_exist', 'hash': md5hex('foo'),
|
|
'bytes': '100', 'content_type': 'text/plain',
|
|
'last_modified': '1970-01-01T00:00:00.000000'},
|
|
{'name': '/gettest/not_checked', 'hash': md5hex('bar'),
|
|
'bytes': '303', 'content_type': 'text/plain',
|
|
'last_modified': '1970-01-01T00:00:00.000000'},
|
|
{'name': '/gettest/made_up',
|
|
'hash': md5hex(md5hex("fizz") + md5hex("buzz")), 'bytes': '2099',
|
|
'content_type': 'application/json',
|
|
'sub_slo': True,
|
|
'last_modified': '1970-01-01T00:00:00.000000'}
|
|
]
|
|
self._setup_manifest('raw', _raw_manifest, extra_headers={
|
|
'Content-Type': 'text/plain',
|
|
}, container='gettest')
|
|
|
|
def _setup_manifest_raw_ranges(self):
|
|
"""
|
|
This is only used by TestSloGetRawManifest; the segments are
|
|
range-segments into regular objects.
|
|
"""
|
|
_raw_ranges_manifest = [
|
|
{'name': '/gettest/does_not_exist', 'hash': md5hex('foo'),
|
|
'bytes': '100', 'last_modified': '1970-01-01T00:00:00.000000',
|
|
'content_type': 'text/plain', 'range': '1-99'},
|
|
{'name': '/gettest/not_checked', 'hash': md5hex('bar'),
|
|
'bytes': '303', 'last_modified': '1970-01-01T00:00:00.000000',
|
|
'content_type': 'text/plain', 'range': '100-200'},
|
|
]
|
|
self._setup_manifest(
|
|
'raw-ranges', _raw_ranges_manifest, extra_headers={
|
|
'Content-Type': 'text/plain',
|
|
}, container='gettest')
|
|
|
|
def test_get_raw_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-raw'
|
|
'?multipart-manifest=get&format=raw',
|
|
environ={'REQUEST_METHOD': 'GET',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
expected_body = json.dumps([
|
|
{'etag': md5hex('foo'), 'size_bytes': '100',
|
|
'path': '/gettest/does_not_exist'},
|
|
{'etag': md5hex('bar'), 'size_bytes': '303',
|
|
'path': '/gettest/not_checked'},
|
|
{'etag': md5hex(md5hex("fizz") + md5hex("buzz")),
|
|
'size_bytes': '2099',
|
|
'path': '/gettest/made_up'}], sort_keys=True)
|
|
expected_etag = md5hex(expected_body)
|
|
if six.PY3:
|
|
expected_body = expected_body.encode('utf-8')
|
|
|
|
self.assertEqual(body, expected_body)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'], expected_etag)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
# raw format should return the actual manifest object content-type
|
|
self.assertEqual(headers['Content-Type'], 'text/plain')
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
|
|
try:
|
|
json.loads(body)
|
|
except ValueError:
|
|
self.fail("Invalid JSON in manifest GET: %r" % body)
|
|
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-raw'
|
|
# FakeSwift stable-alpha-sorts params keys
|
|
'?format=raw&multipart-manifest=get'),
|
|
])
|
|
|
|
def test_get_raw_manifest_passthrough_with_ranges(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-raw-ranges'
|
|
'?multipart-manifest=get&format=raw',
|
|
environ={'REQUEST_METHOD': 'GET',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
# raw format should return the actual manifest object content-type
|
|
self.assertEqual(headers['Content-Type'], 'text/plain')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
try:
|
|
resp_data = json.loads(body)
|
|
except ValueError:
|
|
self.fail("Invalid JSON in manifest GET: %r" % body)
|
|
|
|
self.assertEqual(
|
|
resp_data,
|
|
[{'etag': md5hex('foo'), 'size_bytes': '100',
|
|
'path': '/gettest/does_not_exist', 'range': '1-99'},
|
|
{'etag': md5hex('bar'), 'size_bytes': '303',
|
|
'path': '/gettest/not_checked', 'range': '100-200'}],
|
|
body)
|
|
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-raw-ranges'
|
|
# FakeSwift stable-alpha-sorts params keys
|
|
'?format=raw&multipart-manifest=get'),
|
|
])
|
|
|
|
|
|
class TestSloGetRawOldManifest(TestSloGetRawManifest):
|
|
|
|
modern_manifest_headers = False
|
|
|
|
|
|
class TestSloGetManifests(SloGETorHEADTestCase):
|
|
|
|
modern_manifest_headers = True
|
|
|
|
def setUp(self):
|
|
super(TestSloGetManifests, self).setUp()
|
|
self._setup_alphabet_objects('abcdefghijkl')
|
|
self._setup_manifest_bc()
|
|
self._setup_manifest_abcd()
|
|
self._setup_manifest_abcdefghijkl()
|
|
|
|
def test_get_manifest_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?multipart-manifest=get',
|
|
environ={'REQUEST_METHOD': 'GET',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Type'],
|
|
'application/json; charset=utf-8')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_bc_json_size)
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
try:
|
|
resp_data = json.loads(body)
|
|
except ValueError:
|
|
self.fail("Invalid JSON in manifest GET: %r" % body)
|
|
|
|
self.assertEqual(
|
|
resp_data,
|
|
[{'hash': md5hex('b' * 10), 'bytes': '10', 'name': '/gettest/b_10',
|
|
'content_type': 'text/plain'},
|
|
{'hash': md5hex('c' * 15), 'bytes': '15', 'name': '/gettest/c_15',
|
|
'content_type': 'text/plain'}],
|
|
body)
|
|
self.assertEqual(headers['Etag'], md5hex(body))
|
|
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'
|
|
'?multipart-manifest=get'),
|
|
])
|
|
|
|
def test_get_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
manifest_etag = md5hex(md5hex("b" * 10) + md5hex("c" * 15))
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Length'], '25')
|
|
self.assertEqual(headers['Etag'], '"%s"' % manifest_etag)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['X-Object-Meta-Plant'], 'Ficus')
|
|
self.assertEqual(body, b'bbbbbbbbbbccccccccccccccc')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
])
|
|
|
|
for _, _, hdrs in self.app.calls_with_headers[1:]:
|
|
ua = hdrs.get("User-Agent", "")
|
|
self.assertTrue("SLO MultipartGET" in ua)
|
|
self.assertFalse("SLO MultipartGET SLO MultipartGET" in ua)
|
|
# the first request goes through unaltered
|
|
first_ua = self.app.calls_with_headers[0][2].get("User-Agent")
|
|
self.assertFalse(
|
|
"SLO MultipartGET" in first_ua)
|
|
|
|
def test_get_manifest_repeated_segments_uses_multi_range_requests(self):
|
|
self._setup_manifest_aabbccdd()
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-aabbccdd',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_aabbccdd_slo_size)
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_aabbccdd_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_aabbccdd_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(body, (
|
|
b'aaaaaaaaaabbbbbbbbbbbbbbbbbbbbcccccccccccccccccccccccccccccc'
|
|
b'dddddddddddddddddddddddddddddddddddddddd'))
|
|
|
|
# FakeSwift doesn't have any explicit knowledge of multi-range
|
|
# responses; but swob will convert the response to to MIME documents if
|
|
# it's constructed with body=<bytes>, this test would break with
|
|
# registered segment responses who's body was was a list/app_iter
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-aabbccdd'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
# I won't say it's bad we do this, but it's not obviously only good
|
|
self.assertEqual(ranges, [
|
|
None,
|
|
'bytes=0-4,0-4',
|
|
'bytes=0-9,0-9',
|
|
'bytes=0-14,0-14',
|
|
'bytes=0-19,0-19'])
|
|
|
|
def test_get_manifest_ratelimiting(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcdefghijkl',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
|
|
the_time = [time.time()]
|
|
sleeps = []
|
|
|
|
def mock_time():
|
|
return the_time[0]
|
|
|
|
def mock_sleep(duration):
|
|
sleeps.append(duration)
|
|
the_time[0] += duration
|
|
|
|
with patch('time.time', mock_time), \
|
|
patch('eventlet.sleep', mock_sleep), \
|
|
patch.object(self.slo, 'rate_limit_under_size', 999999999), \
|
|
patch.object(self.slo, 'rate_limit_after_segment', 0):
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK') # sanity check
|
|
self.assertEqual(sleeps, [1.0] * 11)
|
|
|
|
# give the client the first 4 segments without ratelimiting; we'll
|
|
# sleep less
|
|
del sleeps[:]
|
|
with patch('time.time', mock_time), \
|
|
patch('eventlet.sleep', mock_sleep), \
|
|
patch.object(self.slo, 'rate_limit_under_size', 999999999), \
|
|
patch.object(self.slo, 'rate_limit_after_segment', 4):
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK') # sanity check
|
|
self.assertEqual(sleeps, [1.0] * 7)
|
|
|
|
# ratelimit segments under 35 bytes; this affects a-f
|
|
del sleeps[:]
|
|
with patch('time.time', mock_time), \
|
|
patch('eventlet.sleep', mock_sleep), \
|
|
patch.object(self.slo, 'rate_limit_under_size', 35), \
|
|
patch.object(self.slo, 'rate_limit_after_segment', 0):
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK') # sanity check
|
|
self.assertEqual(sleeps, [1.0] * 5)
|
|
|
|
# ratelimit segments under 36 bytes; this now affects a-g, netting
|
|
# us one more sleep than before
|
|
del sleeps[:]
|
|
with patch('time.time', mock_time), \
|
|
patch('eventlet.sleep', mock_sleep), \
|
|
patch.object(self.slo, 'rate_limit_under_size', 36), \
|
|
patch.object(self.slo, 'rate_limit_after_segment', 0):
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK') # sanity check
|
|
self.assertEqual(sleeps, [1.0] * 6)
|
|
|
|
def test_get_manifest_with_submanifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Length'], '50')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(
|
|
body, b'aaaaabbbbbbbbbbcccccccccccccccdddddddddddddddddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
def test_range_get_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=3-17'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '15')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 3-17/50')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(body, b'aabbbbbbbbbbccc')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
'bytes=3-17',
|
|
None,
|
|
'bytes=3-',
|
|
None,
|
|
'bytes=0-2'])
|
|
ignore_range_headers = [
|
|
c[2].get('X-Backend-Ignore-Range-If-Metadata-Present')
|
|
for c in self.app.calls_with_headers]
|
|
self.assertEqual(ignore_range_headers, [
|
|
'X-Static-Large-Object',
|
|
None,
|
|
None,
|
|
None,
|
|
None])
|
|
# we set swift.source for everything but the first request
|
|
self.assertIsNone(self.app.swift_sources[0])
|
|
self.assertEqual(self.app.swift_sources[1:],
|
|
['SLO'] * (len(self.app.swift_sources) - 1))
|
|
|
|
def test_multiple_ranges_get_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=3-17,20-24,35-999999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
|
|
ct, params = parse_content_type(headers['Content-Type'])
|
|
params = dict(params)
|
|
self.assertEqual(ct, 'multipart/byteranges')
|
|
boundary = params.get('boundary')
|
|
self.assertTrue(boundary is not None)
|
|
if six.PY3:
|
|
boundary = boundary.encode('utf-8')
|
|
|
|
self.assertEqual(len(body), int(headers['Content-Length']))
|
|
# this is a multi-range resp
|
|
self.assertNotIn('Content-Range', headers)
|
|
|
|
got_mime_docs = []
|
|
for mime_doc_fh in iter_multipart_mime_documents(
|
|
BytesIO(body), boundary):
|
|
headers = parse_mime_headers(mime_doc_fh)
|
|
body = mime_doc_fh.read()
|
|
got_mime_docs.append((headers, body))
|
|
self.assertEqual(len(got_mime_docs), 3)
|
|
|
|
first_range_headers = got_mime_docs[0][0]
|
|
first_range_body = got_mime_docs[0][1]
|
|
self.assertEqual(first_range_headers['Content-Range'],
|
|
'bytes 3-17/50')
|
|
self.assertEqual(first_range_headers['Content-Type'],
|
|
'application/json')
|
|
self.assertEqual(first_range_body, b'aabbbbbbbbbbccc')
|
|
|
|
second_range_headers = got_mime_docs[1][0]
|
|
second_range_body = got_mime_docs[1][1]
|
|
self.assertEqual(second_range_headers['Content-Range'],
|
|
'bytes 20-24/50')
|
|
self.assertEqual(second_range_headers['Content-Type'],
|
|
'application/json')
|
|
self.assertEqual(second_range_body, b'ccccc')
|
|
|
|
third_range_headers = got_mime_docs[2][0]
|
|
third_range_body = got_mime_docs[2][1]
|
|
self.assertEqual(third_range_headers['Content-Range'],
|
|
'bytes 35-49/50')
|
|
self.assertEqual(third_range_headers['Content-Type'],
|
|
'application/json')
|
|
self.assertEqual(third_range_body, b'ddddddddddddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
'bytes=3-17,20-24,35-999999', # initial GET
|
|
None, # fetch manifest-bc as sub-slo
|
|
'bytes=3-', # a_5
|
|
None, # b_10
|
|
'bytes=0-2,5-9', # c_15
|
|
'bytes=5-']) # d_20
|
|
# we set swift.source for everything but the first request
|
|
self.assertIsNone(self.app.swift_sources[0])
|
|
self.assertEqual(self.app.swift_sources[1:],
|
|
['SLO'] * (len(self.app.swift_sources) - 1))
|
|
|
|
def test_multiple_ranges_including_suffix_get_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=3-17,-21'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
|
|
ct, params = parse_content_type(headers['Content-Type'])
|
|
params = dict(params)
|
|
self.assertEqual(ct, 'multipart/byteranges')
|
|
boundary = params.get('boundary')
|
|
self.assertTrue(boundary is not None)
|
|
if six.PY3:
|
|
boundary = boundary.encode('utf-8')
|
|
|
|
got_mime_docs = []
|
|
for mime_doc_fh in iter_multipart_mime_documents(
|
|
BytesIO(body), boundary):
|
|
headers = parse_mime_headers(mime_doc_fh)
|
|
body = mime_doc_fh.read()
|
|
got_mime_docs.append((headers, body))
|
|
self.assertEqual(len(got_mime_docs), 2)
|
|
|
|
first_range_headers = got_mime_docs[0][0]
|
|
first_range_body = got_mime_docs[0][1]
|
|
self.assertEqual(first_range_headers['Content-Range'],
|
|
'bytes 3-17/50')
|
|
self.assertEqual(first_range_body, b'aabbbbbbbbbbccc')
|
|
|
|
second_range_headers = got_mime_docs[1][0]
|
|
second_range_body = got_mime_docs[1][1]
|
|
self.assertEqual(second_range_headers['Content-Range'],
|
|
'bytes 29-49/50')
|
|
self.assertEqual(second_range_body, b'cdddddddddddddddddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
'bytes=3-17,-21', # initial GET
|
|
None, # fetch manifest-bc as sub-slo
|
|
'bytes=3-', # a_5
|
|
None, # b_10
|
|
'bytes=0-2,14-', # c_15
|
|
None]) # d_20
|
|
|
|
|
|
class TestSloGetOldManifests(TestSloGetManifests):
|
|
|
|
modern_manifest_headers = False
|
|
|
|
def test_get_manifest_with_submanifest_bytes_in_content_type(self):
|
|
_abcd_alt_manifest = [
|
|
{'name': '/gettest/a_5', 'hash': md5hex("a" * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/manifest-bc', 'sub_slo': True,
|
|
'hash': self.manifest_bc_slo_etag,
|
|
# if swift 1.9.1 thru 1.12.0 let this sub-slo manifest get created
|
|
# with invalid hash/bytes the sub-request SLO GET will still work
|
|
'content_type': 'application/json; swift_bytes=25',
|
|
'bytes': self.manifest_bc_json_size},
|
|
{'name': '/gettest/d_20', 'hash': md5hex("d" * 20),
|
|
'content_type': 'text/plain', 'bytes': '20'}
|
|
]
|
|
# N.B. if the _setup_manifest helper is called from a class w/
|
|
# modern_manifest_headers = True the fake sysmeta is calculated from
|
|
# the manifests provided bytes; real modern swift would have rejected
|
|
# the PUT when the HEAD resp showed a size mis-match with sub-slo resp
|
|
self._setup_manifest('abcd-alt', _abcd_alt_manifest,
|
|
container='gettest')
|
|
# verify correct content-length when the sub-slo segment in the
|
|
# manifest has its actual object content-length appended as swift_bytes
|
|
# to the content-type, and the submanifest length in the bytes field.
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-alt',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Length'], '50')
|
|
# N.B. we use the same slo-etag hash for the sub-slo segment to
|
|
# calculate the alt-slo-etag
|
|
self.assertEqual(self.manifest_abcd_slo_etag,
|
|
self.manifest_abcd_alt_slo_etag)
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_abcd_alt_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_alt_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(
|
|
body, b'aaaaabbbbbbbbbbcccccccccccccccdddddddddddddddddddd')
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd-alt'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
|
|
class TestOldSwiftWithRanges(SloGETorHEADTestCase):
|
|
|
|
# Proxies have been writing moden slo-sysmeta since 2016; object servers
|
|
# only started ignoring Range headers on x-static-large-object in 2020 and
|
|
# it works on legacy manifests.
|
|
modern_manifest_headers = True
|
|
|
|
def setUp(self):
|
|
super(TestOldSwiftWithRanges, self).setUp()
|
|
# old swift didn't know how to ignore range headers and respond with
|
|
# the whole object/manifest when specific metadata was present
|
|
self.app.can_ignore_range = False
|
|
|
|
self._setup_alphabet_objects('abcd')
|
|
self._setup_manifest_bc()
|
|
self._setup_manifest_bc_ranges()
|
|
self._setup_manifest_abcd()
|
|
self._setup_manifest_abcd_ranges()
|
|
self._setup_big_manifest()
|
|
|
|
def _setup_big_manifest(self):
|
|
big = b'e' * 1024 * 1024
|
|
big_etag = md5hex(big)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/big_seg',
|
|
swob.HTTPOk, {'Content-Type': 'application/foo',
|
|
'Etag': big_etag}, big)
|
|
self._setup_manifest('big_man', [
|
|
{'name': '/gettest/big_seg', 'hash': big_etag,
|
|
'bytes': 1024 * 1024, 'content_type': 'application/foo'},
|
|
], extra_headers={
|
|
'X-Backend-Timestamp': '1234',
|
|
}, container='gettest', obj_key='big_manifest')
|
|
|
|
def test_old_swift_range_get_includes_whole_manifest(self):
|
|
# If the first range GET results in retrieval of the entire manifest
|
|
# body (and not because of X-Backend-Ignore-Range-If-Metadata-Present,
|
|
# but because the requested range happened to be sufficient which we
|
|
# detected by looking at the Content-Range response header), then we
|
|
# should not go make a second, non-ranged request just to retrieve the
|
|
# same bytes again.
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=0-999999999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_abcd_slo_size)
|
|
self.assertEqual(
|
|
body, b'aaaaabbbbbbbbbbcccccccccccccccdddddddddddddddddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
def test_old_swift_range_get_beyond_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/big_manifest',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=100000-199999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_big_man_slo_etag)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_big_man_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
if six.PY3:
|
|
count_e = sum(1 if x == 'e' else 0
|
|
for x in body.decode('ascii', errors='replace'))
|
|
else:
|
|
count_e = sum(1 if x == 'e' else 0 for x in body)
|
|
self.assertEqual(count_e, 100000)
|
|
self.assertEqual(len(body) - count_e, 0)
|
|
|
|
self.assertEqual(
|
|
self.app.calls, [
|
|
# has Range header, gets 416
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
# old swift can't ignore range request to manifest and we have
|
|
# to refetch; new swift has exactly the same behavior but w/o
|
|
# this extra refetch request as lots of other tests demonstrate
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
('GET',
|
|
'/v1/AUTH_test/gettest/big_seg?multipart-manifest=get')])
|
|
self.assertEqual('bytes=100000-199999', self.app.headers[0]['Range'])
|
|
self.assertNotIn('Range', self.app.headers[1])
|
|
self.assertEqual('bytes=100000-199999', self.app.headers[2]['Range'])
|
|
|
|
def test_old_swift_range_get_beyond_manifest_refetch_fails(self):
|
|
# new swift would have ignored the range and got the whole
|
|
# manifest on the first try and therefore never have attempted
|
|
# this second refetch which fails
|
|
self.app.register_next_response(
|
|
'GET', '/v1/AUTH_test/gettest/big_manifest',
|
|
swob.HTTPNotFound, {}, None)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/big_manifest',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=100000-199999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '503 Service Unavailable')
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(self.app.calls, [
|
|
# has Range header, gets 416
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
# retry the first one
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
])
|
|
|
|
def test_old_swift_range_get_beyond_manifest_refetch_finds_old(self):
|
|
# new swift would have ignored the range and got the whole
|
|
# manifest on the first try and therefore never have attempted
|
|
# this second refetch which is too old
|
|
self.app.register_next_response(
|
|
'GET', '/v1/AUTH_test/gettest/big_manifest',
|
|
swob.HTTPOk, {'X-Backend-Timestamp': '1233'}, [b'small body'])
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/big_manifest',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=100000-199999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '503 Service Unavailable')
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(self.app.calls, [
|
|
# has Range header, gets 416
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
# retry the first one
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
])
|
|
|
|
def test_old_swift_range_get_beyond_manifest_refetch_small_non_slo(self):
|
|
# new swift would have ignored the range and got the whole
|
|
# manifest on the first try and therefore never have attempted
|
|
# this second refetch which isn't an SLO
|
|
self.app.register_next_response(
|
|
'GET', '/v1/AUTH_test/gettest/big_manifest',
|
|
swob.HTTPOk, {'X-Backend-Timestamp': '1235'}, [b'small body'])
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/big_manifest',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=100000-199999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(self.app.calls, [
|
|
# has Range header, gets 416
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
# retry the first one
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
])
|
|
# swob is converting the successful non-slo response to conditional
|
|
# error and closing our unconditionally refetched resp_iter
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest')] = 1
|
|
|
|
def test_old_swift_range_get_beyond_manifest_refetch_big_non_slo(self):
|
|
# new swift would have ignored the range and got the whole
|
|
# manifest on the first try and therefore never have attempted
|
|
# this second refetch which isn't an SLO
|
|
self.app.register_next_response(
|
|
'GET', '/v1/AUTH_test/gettest/big_manifest',
|
|
swob.HTTPOk, {'X-Backend-Timestamp': '1235'}, [b'x' * 1024 * 1024])
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/big_manifest',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=100000-199999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK') # NOT 416 or 206!
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(len(body), 1024 * 1024)
|
|
self.assertEqual(body, b'x' * 1024 * 1024)
|
|
self.assertEqual(self.app.calls, [
|
|
# has Range header, gets 416
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
# retry the first one
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
])
|
|
|
|
def test_old_swift_range_get_beyond_manifest_refetch_tombstone(self):
|
|
# new swift would have ignored the range and got the whole
|
|
# manifest on the first try and therefore never have attempted
|
|
# this second refetch which shows it was deleted
|
|
self.app.register_next_response(
|
|
'GET', '/v1/AUTH_test/gettest/big_manifest',
|
|
swob.HTTPNotFound, {'X-Backend-Timestamp': '1345'}, None)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/big_manifest',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=100000-199999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '404 Not Found')
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(self.app.calls, [
|
|
# has Range header, gets 416
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
# retry the first one
|
|
('GET', '/v1/AUTH_test/gettest/big_manifest'),
|
|
])
|
|
|
|
def test_old_swift_range_get_bogus_content_range(self):
|
|
# Just a little paranoia; Swift currently sends back valid
|
|
# Content-Range headers, but if somehow someone sneaks an invalid one
|
|
# in there, we'll ignore it, when sniffing a 206 manifest response.
|
|
|
|
def content_range_breaker_factory(app):
|
|
def content_range_breaker(env, start_response):
|
|
req = swob.Request(env)
|
|
resp = req.get_response(app)
|
|
resp.headers['Content-Range'] = 'triscuits'
|
|
return resp(env, start_response)
|
|
return content_range_breaker
|
|
|
|
self.slo = slo.filter_factory({})(
|
|
content_range_breaker_factory(self.app))
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=0-999999999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(
|
|
body, b'aaaaabbbbbbbbbbcccccccccccccccdddddddddddddddddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
# new swift would have ignored the range and got the whole
|
|
# manifest on the first try and therefore never have attempted to
|
|
# look at Content-Range; new swift has exactly the same behavior
|
|
# but w/o this extra refetch request, however on new swift the
|
|
# broken content-range in the resp isn't intresting or relevant
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
def test_old_swift_range_get_includes_whole_range_manifest(self):
|
|
# If the first range GET results in retrieval of the entire manifest
|
|
# body (and not because of X-Backend-Ignore-Range-If-Metadata-Present,
|
|
# but because the requested range happened to be sufficient which we
|
|
# detected by looking at the Content-Range response header), then we
|
|
# should not go make a second, non-ranged request just to retrieve the
|
|
# same bytes again.
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-ranges',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=0-999999999'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '32')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(body, b'aaaaaaaaccccccccbbbbbbbbdddddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
'bytes=0-999999999',
|
|
None,
|
|
'bytes=0-3,1-',
|
|
'bytes=0-3,11-',
|
|
'bytes=4-7,2-5',
|
|
'bytes=0-3,8-11'])
|
|
# we set swift.source for everything but the first request
|
|
self.assertIsNone(self.app.swift_sources[0])
|
|
self.assertEqual(self.app.swift_sources[1:],
|
|
['SLO'] * (len(self.app.swift_sources) - 1))
|
|
|
|
|
|
class TestOldSwiftWithRangesOldManifests(TestOldSwiftWithRanges):
|
|
|
|
# Proxies have been writing modern slo-sysmeta since 2016; object servers
|
|
# only started ignoring Range headers on x-static-large-object in 2020 and
|
|
# it works on legacy manifests.
|
|
modern_manifest_headers = False
|
|
|
|
|
|
class TestSloRangeRequests(SloGETorHEADTestCase):
|
|
|
|
modern_manifest_headers = True
|
|
|
|
def setUp(self):
|
|
super(TestSloRangeRequests, self).setUp()
|
|
self._setup_alphabet_objects('abcd')
|
|
self._setup_manifest_bc()
|
|
self._setup_manifest_abcd()
|
|
self._setup_manifest_bc_ranges()
|
|
self._setup_manifest_abcd_ranges()
|
|
self._setup_manifest_abcd_subranges()
|
|
|
|
def test_range_get_manifest_on_segment_boundaries(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=5-29'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '25')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 5-29/50')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(body, b'bbbbbbbbbbccccccccccccccc')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')])
|
|
|
|
headers = [c[2] for c in self.app.calls_with_headers]
|
|
self.assertEqual(headers[0].get('Range'), 'bytes=5-29')
|
|
self.assertIsNone(headers[1].get('Range'))
|
|
self.assertIsNone(headers[2].get('Range'))
|
|
self.assertIsNone(headers[3].get('Range'))
|
|
|
|
def test_range_get_manifest_first_byte(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=0-0'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '1')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 0-0/50')
|
|
self.assertEqual(body, b'a')
|
|
|
|
# Make sure we don't get any objects we don't need, including
|
|
# submanifests.
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get')])
|
|
|
|
def test_range_get_manifest_sub_slo(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=25-30'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '6')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 25-30/50')
|
|
self.assertEqual(body, b'cccccd')
|
|
|
|
# Make sure we don't get any objects we don't need, including
|
|
# submanifests.
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
def test_range_get_manifest_overlapping_end(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=45-55'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '5')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 45-49/50')
|
|
self.assertEqual(body, b'ddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
def test_range_get_manifest_unsatisfiable(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=100-200'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
|
|
def test_get_segment_with_non_ascii_path(self):
|
|
segment_body = u"a møøse once bit my sister".encode("utf-8")
|
|
segment_etag = md5(segment_body, usedforsecurity=False).hexdigest()
|
|
if six.PY2:
|
|
path = u'/v1/AUTH_test/ünicode/öbject-segment'.encode('utf-8')
|
|
else:
|
|
path = str_to_wsgi(u'/v1/AUTH_test/ünicode/öbject-segment')
|
|
self.app.register(
|
|
'GET', path,
|
|
swob.HTTPOk, {'Content-Length': str(len(segment_body)),
|
|
'Etag': segment_etag},
|
|
segment_body)
|
|
|
|
manifest_json = json.dumps([{'name': u'/ünicode/öbject-segment',
|
|
'hash': segment_etag,
|
|
'content_type': 'text/plain',
|
|
'bytes': len(segment_body)}])
|
|
if six.PY2:
|
|
path = u'/v1/AUTH_test/ünicode/manifest'.encode('utf-8')
|
|
else:
|
|
path = str_to_wsgi(u'/v1/AUTH_test/ünicode/manifest')
|
|
self.app.register(
|
|
'GET', path,
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'Content-Length': str(len(manifest_json)),
|
|
'X-Static-Large-Object': 'true'},
|
|
manifest_json.encode('ascii'))
|
|
|
|
req = Request.blank(
|
|
str_to_wsgi('/v1/AUTH_test/ünicode/manifest'),
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(body, segment_body)
|
|
|
|
def test_get_range_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-ranges',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Length'], '32')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(body, b'aaaaaaaaccccccccbbbbbbbbdddddddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
None,
|
|
None,
|
|
'bytes=0-3,1-',
|
|
'bytes=0-3,11-',
|
|
'bytes=4-7,2-5',
|
|
'bytes=0-3,8-11'])
|
|
# we set swift.source for everything but the first request
|
|
self.assertIsNone(self.app.swift_sources[0])
|
|
self.assertEqual(
|
|
self.app.swift_sources[1:],
|
|
['SLO'] * (len(self.app.swift_sources) - 1)
|
|
)
|
|
self.assertEqual(md5hex(''.join([
|
|
md5hex('a' * 5), ':0-3;',
|
|
md5hex('a' * 5), ':1-4;',
|
|
self.manifest_bc_ranges_slo_etag, ':8-15;',
|
|
self.manifest_bc_ranges_slo_etag, ':0-7;',
|
|
md5hex('d' * 20), ':0-3;',
|
|
md5hex('d' * 20), ':8-11;',
|
|
])), headers['Etag'].strip('"'))
|
|
|
|
def test_get_subrange_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Length'], '17')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(body, b'aacccdccbbbabbddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd-subranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
None,
|
|
None,
|
|
None,
|
|
'bytes=3-',
|
|
'bytes=0-2',
|
|
'bytes=11-11',
|
|
'bytes=13-',
|
|
'bytes=4-6',
|
|
'bytes=0-0',
|
|
'bytes=4-5',
|
|
'bytes=0-2'])
|
|
# we set swift.source for everything but the first request
|
|
self.assertIsNone(self.app.swift_sources[0])
|
|
self.assertEqual(self.app.swift_sources[1:],
|
|
['SLO'] * (len(self.app.swift_sources) - 1))
|
|
|
|
def test_range_get_range_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-ranges',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=7-26'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '20')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 7-26/32')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertIn('Etag', headers)
|
|
self.assertEqual(body, b'accccccccbbbbbbbbddd')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
'bytes=7-26',
|
|
None,
|
|
'bytes=4-',
|
|
'bytes=0-3,11-',
|
|
'bytes=4-7,2-5',
|
|
'bytes=0-2'])
|
|
# we set swift.source for everything but the first request
|
|
self.assertIsNone(self.app.swift_sources[0])
|
|
self.assertEqual(self.app.swift_sources[1:],
|
|
['SLO'] * (len(self.app.swift_sources) - 1))
|
|
|
|
def test_range_get_subrange_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=4-12'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '9')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 4-12/17')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(body, b'cdccbbbab')
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd-subranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get')])
|
|
|
|
ranges = [c[2].get('Range') for c in self.app.calls_with_headers]
|
|
self.assertEqual(ranges, [
|
|
'bytes=4-12',
|
|
None,
|
|
None,
|
|
'bytes=2-2',
|
|
'bytes=11-11',
|
|
'bytes=13-',
|
|
'bytes=4-6',
|
|
'bytes=0-0',
|
|
'bytes=4-4'])
|
|
# we set swift.source for everything but the first request
|
|
self.assertIsNone(self.app.swift_sources[0])
|
|
self.assertEqual(self.app.swift_sources[1:],
|
|
['SLO'] * (len(self.app.swift_sources) - 1))
|
|
|
|
|
|
class TestSloRangeRequestsOldManifest(TestSloRangeRequests):
|
|
|
|
modern_manifest_headers = False
|
|
|
|
|
|
class TestSloErrors(SloGETorHEADTestCase):
|
|
|
|
modern_manifest_headers = True
|
|
|
|
def setUp(self):
|
|
super(TestSloErrors, self).setUp()
|
|
self._setup_alphabet_objects('abcd')
|
|
self._setup_manifest_bc()
|
|
self._setup_manifest_abcd()
|
|
|
|
self._setup_manifest('badetag', [
|
|
{'name': '/gettest/a_5', 'hash': 'wrong!',
|
|
'content_type': 'text/plain', 'bytes': '5'}
|
|
], container='gettest')
|
|
self._setup_manifest('badsize', [
|
|
{'name': '/gettest/a_5', 'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain', 'bytes': '999999'},
|
|
], container='gettest')
|
|
|
|
def test_slo_sysmeta_on_error(self):
|
|
headers = {
|
|
'Content-Type': 'application/octet-stream',
|
|
'X-Static-Large-Object': 'true',
|
|
'X-Object-Meta-Animal': 'Pig',
|
|
}
|
|
if self.modern_manifest_headers:
|
|
headers.update({
|
|
'X-Object-Sysmeta-Slo-Etag': 'badmeta-etag',
|
|
'X-Object-Sysmeta-Slo-Size': '123',
|
|
})
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-badmeta',
|
|
swob.HTTPNotFound, headers)
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-badmeta')
|
|
status, headers, body = self.call_slo(req)
|
|
# slo metadata on error response!? there's a bug somewhere.
|
|
self.assertEqual(status, '500 Internal Error')
|
|
self.assertEqual(body, b'Unable to load SLO manifest')
|
|
self.assertNotIn('X-Object-Meta-Animal', headers)
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-badmeta')])
|
|
|
|
def test_get_bogus_manifest(self):
|
|
headers = {
|
|
'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true',
|
|
'X-Object-Meta-Fish': 'Bass',
|
|
}
|
|
if self.modern_manifest_headers:
|
|
headers.update({
|
|
'X-Object-Sysmeta-Slo-Etag': 'badjson-etag',
|
|
'X-Object-Sysmeta-Slo-Size': '123',
|
|
})
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-badjson',
|
|
swob.HTTPOk, headers,
|
|
"[not {json (at ++++all")
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-badjson',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
# This often (usually?) happens because of an incomplete read -- the
|
|
# proxy app started getting a large manifest and sending it back to
|
|
# SLO, then there was a timeout or something, couldn't resume in time,
|
|
# and we've got just part of a JSON document. Having the client retry
|
|
# seems reasonable
|
|
self.assertEqual(status, '500 Internal Error')
|
|
self.assertEqual(body, b'Unable to load SLO manifest')
|
|
self.assertNotIn('X-Object-Meta-Fish', headers)
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-badjson')])
|
|
|
|
def test_get_invalid_sysmeta_passthrough(self):
|
|
# in an attempt to workaround lp bug#2035158 s3api used to set some
|
|
# invalid slo/s3api sysmeta, we will always have some data stored with
|
|
# empty values for these headers, but they're not SLOs and are missing
|
|
# the X-Static-Large-Object marker sysmeta (thank goodness!)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/bucket+segments/obj/upload-id/1',
|
|
swob.HTTPOk, {
|
|
'X-Object-Sysmeta-S3Api-Acl': "{'some': 'json'}",
|
|
'X-Object-Sysmeta-S3Api-Etag': '',
|
|
'X-Object-Sysmeta-Slo-Etag': '',
|
|
'X-Object-Sysmeta-Slo-Size': '',
|
|
'X-Object-Sysmeta-Swift3-Etag': '',
|
|
}, "any seg created with copy-part")
|
|
req = Request.blank('/v1/AUTH_test/bucket+segments/obj/upload-id/1')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertEqual(body, b"any seg created with copy-part")
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/bucket+segments/obj/upload-id/1'),
|
|
])
|
|
|
|
def _do_test_generator_closure(self, leaks):
|
|
# Test that the SLO WSGI iterable closes its internal .app_iter when
|
|
# it receives a close() message.
|
|
#
|
|
# This is sufficient to fix a memory leak. The memory leak arises
|
|
# due to cyclic references involving a running generator; a running
|
|
# generator sometimes preventes the GC from collecting it in the
|
|
# same way that an object with a defined __del__ does.
|
|
#
|
|
# There are other ways to break the cycle and fix the memory leak as
|
|
# well; calling .close() on the generator is sufficient, but not
|
|
# necessary. However, having this test is better than nothing for
|
|
# preventing regressions.
|
|
class LeakTracker(object):
|
|
def __init__(self, inner_iter):
|
|
leaks[0] += 1
|
|
self.inner_iter = iter(inner_iter)
|
|
|
|
def __iter__(self):
|
|
return self
|
|
|
|
def next(self):
|
|
return next(self.inner_iter)
|
|
__next__ = next
|
|
|
|
def close(self):
|
|
leaks[0] -= 1
|
|
close_if_possible(self.inner_iter)
|
|
|
|
class LeakTrackingSegmentedIterable(slo.SegmentedIterable):
|
|
def _internal_iter(self, *a, **kw):
|
|
it = super(
|
|
LeakTrackingSegmentedIterable, self)._internal_iter(
|
|
*a, **kw)
|
|
return LeakTracker(it)
|
|
|
|
status = [None]
|
|
headers = [None]
|
|
|
|
def start_response(s, h, ei=None):
|
|
status[0] = s
|
|
headers[0] = h
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET',
|
|
'HTTP_ACCEPT': 'application/json'})
|
|
|
|
# can't self.call_slo() here since we don't want to consume the
|
|
# whole body
|
|
with patch.object(slo, 'SegmentedIterable',
|
|
LeakTrackingSegmentedIterable):
|
|
app_resp = self.slo(req.environ, start_response)
|
|
self.assertEqual(status[0], '200 OK') # sanity check
|
|
return app_resp
|
|
|
|
def test_generator_closure(self):
|
|
leaks = [0]
|
|
app_resp = self._do_test_generator_closure(leaks)
|
|
body_iter = iter(app_resp)
|
|
chunk = next(body_iter)
|
|
self.assertEqual(chunk, b'aaaaa') # sanity check
|
|
app_resp.close()
|
|
self.assertEqual(0, leaks[0])
|
|
# we closed before reading all chunks
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get')] = 1
|
|
|
|
def test_generator_closure_iter_app_resp(self):
|
|
# verify that the result of iter(app_resp) has a close method that
|
|
# closes app_resp
|
|
leaks = [0]
|
|
app_resp = self._do_test_generator_closure(leaks)
|
|
body_iter = iter(app_resp)
|
|
chunk = next(body_iter)
|
|
self.assertEqual(chunk, b'aaaaa') # sanity check
|
|
close_method = getattr(body_iter, 'close', None)
|
|
self.assertIsNotNone(close_method)
|
|
self.assertTrue(callable(close_method))
|
|
close_method()
|
|
self.assertEqual(0, leaks[0])
|
|
# we closed before reading all chunks
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get')] = 1
|
|
|
|
def test_recursion_limit(self):
|
|
# man1 points to obj1 and man2, man2 points to obj2 and man3...
|
|
for i in range(20):
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/obj%d' % i,
|
|
swob.HTTPOk, {'Content-Type': 'text/plain',
|
|
'Etag': md5hex('body%02d' % i)},
|
|
b'body%02d' % i)
|
|
|
|
manifest_name = 'man1'
|
|
manifest_data = [{'name': '/gettest/obj20',
|
|
'hash': md5hex('body20'),
|
|
'content_type': 'text/plain',
|
|
'bytes': '6'}]
|
|
self._setup_manifest(manifest_name, manifest_data,
|
|
container='gettest', obj_key='man1')
|
|
|
|
submanifest_bytes = 6
|
|
for i in range(19, 0, -1):
|
|
manifest_name = 'man%d' % i
|
|
manifest_data = [
|
|
{'name': '/gettest/obj%d' % i,
|
|
'hash': md5hex('body%02d' % i),
|
|
'bytes': '6',
|
|
'content_type': 'text/plain'},
|
|
{'data': base64.b64encode(b'-' * 3).decode('ascii')},
|
|
{'name': '/gettest/man%d' % (i + 1),
|
|
'hash': 'man%d' % (i + 1),
|
|
'sub_slo': True,
|
|
'bytes': submanifest_bytes,
|
|
'content_type': 'application/json'}]
|
|
self._setup_manifest(manifest_name, manifest_data,
|
|
container='gettest', obj_key=manifest_name)
|
|
|
|
submanifest_bytes += 9
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/man1',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
# we don't know at header-sending time that things are going to go
|
|
# wrong, so we end up with a 200 and a truncated body
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Content-Length'], str(9 * 19 + 6))
|
|
self.assertEqual(body, (
|
|
b'body01---body02---body03---body04---body05---'
|
|
b'body06---body07---body08---body09---body10---'))
|
|
# but the error shows up in logs
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
"While processing manifest '/v1/AUTH_test/gettest/man1', "
|
|
"max recursion depth was exceeded"
|
|
])
|
|
# make sure we didn't keep asking for segments
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/man1'),
|
|
('GET', '/v1/AUTH_test/gettest/obj1?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man2'),
|
|
('GET', '/v1/AUTH_test/gettest/obj2?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man3'),
|
|
('GET', '/v1/AUTH_test/gettest/obj3?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man4'),
|
|
('GET', '/v1/AUTH_test/gettest/obj4?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man5'),
|
|
('GET', '/v1/AUTH_test/gettest/obj5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man6'),
|
|
('GET', '/v1/AUTH_test/gettest/obj6?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man7'),
|
|
('GET', '/v1/AUTH_test/gettest/obj7?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man8'),
|
|
('GET', '/v1/AUTH_test/gettest/obj8?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man9'),
|
|
('GET', '/v1/AUTH_test/gettest/obj9?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/man10'),
|
|
('GET', '/v1/AUTH_test/gettest/obj10?multipart-manifest=get'),
|
|
])
|
|
|
|
def test_sub_slo_recursion(self):
|
|
# man1 points to man2 and obj1, man2 points to man3 and obj2...
|
|
for i in range(11):
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/obj%d' % i,
|
|
swob.HTTPOk, {'Content-Type': 'text/plain',
|
|
'Content-Length': '6',
|
|
'Etag': md5hex('body%02d' % i)},
|
|
b'body%02d' % i)
|
|
|
|
manifest_name = 'man%d' % i
|
|
manifest_data = [{'name': '/gettest/obj%d' % i,
|
|
'hash': md5hex('body%2d' % i),
|
|
'content_type': 'text/plain',
|
|
'bytes': '6'}]
|
|
self._setup_manifest(manifest_name, manifest_data,
|
|
container='gettest', obj_key=manifest_name)
|
|
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/gettest/obj%d' % i,
|
|
swob.HTTPOk, {'Content-Length': '6',
|
|
'Etag': md5hex('body%2d' % i)},
|
|
None)
|
|
|
|
for i in range(9, 0, -1):
|
|
manifest_name = 'man%d' % i
|
|
manifest_data = [
|
|
{'name': '/gettest/man%d' % (i + 1),
|
|
'hash': 'man%d' % (i + 1),
|
|
'sub_slo': True,
|
|
'bytes': (10 - i) * 6,
|
|
'content_type': 'application/json'},
|
|
{'name': '/gettest/obj%d' % i,
|
|
'hash': md5hex('body%02d' % i),
|
|
'bytes': '6',
|
|
'content_type': 'text/plain'}]
|
|
self._setup_manifest(manifest_name, manifest_data,
|
|
container='gettest', obj_key=manifest_name)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/man1',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(body, (b'body10body09body08body07body06'
|
|
b'body05body04body03body02body01'))
|
|
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/man1'),
|
|
('GET', '/v1/AUTH_test/gettest/man2'),
|
|
('GET', '/v1/AUTH_test/gettest/man3'),
|
|
('GET', '/v1/AUTH_test/gettest/man4'),
|
|
('GET', '/v1/AUTH_test/gettest/man5'),
|
|
('GET', '/v1/AUTH_test/gettest/man6'),
|
|
('GET', '/v1/AUTH_test/gettest/man7'),
|
|
('GET', '/v1/AUTH_test/gettest/man8'),
|
|
('GET', '/v1/AUTH_test/gettest/man9'),
|
|
('GET', '/v1/AUTH_test/gettest/man10'),
|
|
('GET', '/v1/AUTH_test/gettest/obj10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj9?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj8?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj7?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj6?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj4?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj3?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj2?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/obj1?multipart-manifest=get'),
|
|
])
|
|
|
|
def test_sub_slo_recursion_limit(self):
|
|
# man1 points to man2 and obj1, man2 points to man3 and obj2...
|
|
for i in range(12):
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/obj%d' % i,
|
|
swob.HTTPOk,
|
|
{'Content-Type': 'text/plain',
|
|
'Content-Length': '6',
|
|
'Etag': md5hex('body%02d' % i)},
|
|
b'body%02d' % i)
|
|
|
|
manifest_name = 'man%d' % i
|
|
manifest_data = [{'name': '/gettest/obj%d' % i,
|
|
'hash': md5hex('body%2d' % i),
|
|
'content_type': 'text/plain',
|
|
'bytes': '6'}]
|
|
self._setup_manifest(manifest_name, manifest_data,
|
|
container='gettest', obj_key=manifest_name)
|
|
|
|
self.app.register(
|
|
'HEAD', '/v1/AUTH_test/gettest/obj%d' % i,
|
|
swob.HTTPOk, {'Content-Length': '6',
|
|
'Etag': md5hex('body%2d' % i)},
|
|
None)
|
|
|
|
for i in range(11, 0, -1):
|
|
manifest_name = 'man%d' % i
|
|
manifest_data = [
|
|
{'name': '/gettest/man%d' % (i + 1),
|
|
'hash': 'man%d' % (i + 1),
|
|
'sub_slo': True,
|
|
'bytes': (12 - i) * 6,
|
|
'content_type': 'application/json'},
|
|
{'name': '/gettest/obj%d' % i,
|
|
'hash': md5hex('body%02d' % i),
|
|
'bytes': '6',
|
|
'content_type': 'text/plain'}]
|
|
self._setup_manifest(manifest_name, manifest_data,
|
|
container='gettest', obj_key=manifest_name)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/man1',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '409 Conflict')
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/man1'),
|
|
('GET', '/v1/AUTH_test/gettest/man2'),
|
|
('GET', '/v1/AUTH_test/gettest/man3'),
|
|
('GET', '/v1/AUTH_test/gettest/man4'),
|
|
('GET', '/v1/AUTH_test/gettest/man5'),
|
|
('GET', '/v1/AUTH_test/gettest/man6'),
|
|
('GET', '/v1/AUTH_test/gettest/man7'),
|
|
('GET', '/v1/AUTH_test/gettest/man8'),
|
|
('GET', '/v1/AUTH_test/gettest/man9'),
|
|
('GET', '/v1/AUTH_test/gettest/man10'),
|
|
])
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
"While processing manifest '/v1/AUTH_test/gettest/man1', "
|
|
"max recursion depth was exceeded"
|
|
])
|
|
|
|
def test_error_fetching_segment(self):
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/c_15',
|
|
swob.HTTPUnauthorized, {}, None)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(b"aaaaabbbbbbbbbb", body)
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'While processing manifest /v1/AUTH_test/gettest/manifest-abcd, '
|
|
'got 401 (<html><h1>Unauthorized</h1><p>This server could not '
|
|
'verif...) while retrieving /v1/AUTH_test/gettest/c_15'
|
|
])
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
# This one has the error, and so is the last one we fetch.
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')])
|
|
|
|
def test_error_fetching_submanifest(self):
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/manifest-bc',
|
|
swob.HTTPUnauthorized, {}, None)
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual("200 OK", status)
|
|
self.assertEqual(b"aaaaa", body)
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'while fetching /v1/AUTH_test/gettest/manifest-abcd, GET of '
|
|
'submanifest /v1/AUTH_test/gettest/manifest-bc failed with '
|
|
'status 401 (<html><h1>Unauthorized</h1><p>This server could '
|
|
'not verif...)'
|
|
])
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
# TODO: skip coalecse until validate to re-order sub-manifest req
|
|
# This one has the error, and so is the last one we fetch.
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
# But we were looking ahead to see if we could combine ranges,
|
|
# so we still get the first segment out
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get')])
|
|
|
|
def test_error_fetching_first_segment_submanifest(self):
|
|
# This differs from the normal submanifest error because this one
|
|
# happens before we've actually sent any response body.
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-a',
|
|
swob.HTTPForbidden, {}, None)
|
|
self._setup_manifest('manifest-a', [
|
|
{'name': '/gettest/manifest-a', 'sub_slo': True,
|
|
'content_type': 'application/json',
|
|
'hash': 'manifest-a', 'bytes': '12345'},
|
|
], container='gettest')
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-manifest-a',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('409 Conflict', status)
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'while fetching /v1/AUTH_test/gettest/manifest-manifest-a, GET '
|
|
'of submanifest /v1/AUTH_test/gettest/manifest-a failed with '
|
|
'status 403 (<html><h1>Forbidden</h1><p>Access was denied to '
|
|
'this reso...)'
|
|
])
|
|
|
|
def test_invalid_json_submanifest(self):
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-bc',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true',
|
|
'X-Object-Meta-Plant': 'Ficus'},
|
|
"[this {isn't (JSON")
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, b'aaaaa')
|
|
lines = self.slo.logger.get_lines_for_level('error')
|
|
self.assertEqual(lines, [
|
|
mock.ANY,
|
|
'while fetching /v1/AUTH_test/gettest/manifest-abcd, '
|
|
'JSON-decoding of submanifest /v1/AUTH_test/gettest/manifest-bc '
|
|
'failed with 500 Internal Error'
|
|
])
|
|
self.assertIn(lines[0], [
|
|
# py2
|
|
'Unable to load SLO manifest: '
|
|
'No JSON object could be decoded',
|
|
# py3
|
|
'Unable to load SLO manifest: '
|
|
'Expecting value: line 1 column 2 (char 1)',
|
|
])
|
|
|
|
def test_mismatched_etag(self):
|
|
self._setup_manifest('a-b-badetag-c', [
|
|
{'name': '/gettest/a_5', 'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/b_10', 'hash': 'wrong!',
|
|
'content_type': 'text/plain', 'bytes': '10'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15),
|
|
'content_type': 'text/plain', 'bytes': '15'},
|
|
], container='gettest')
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-a-b-badetag-c',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(headers['Etag'], '"%s"' %
|
|
self.manifest_a_b_badetag_c_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_a_b_badetag_c_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(body, b'aaaaa')
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'Object segment no longer valid: /v1/AUTH_test/gettest/b_10 '
|
|
'etag: 82136b4240d6ce4ea7d03e51469a393b != wrong! or 10 != 10.'
|
|
])
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-a-b-badetag-c'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
])
|
|
# we don't drain the segment's resp_iter if validation fails
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get')] = 1
|
|
|
|
def test_mismatched_size(self):
|
|
self._setup_manifest('a-b-badsize-c', [
|
|
{'name': '/gettest/a_5', 'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/b_10', 'hash': md5hex('b' * 10),
|
|
'content_type': 'text/plain', 'bytes': '999999'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15),
|
|
'content_type': 'text/plain', 'bytes': '15'},
|
|
], container='gettest')
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-a-b-badsize-c',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, b'aaaaa')
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'Object segment no longer valid: /v1/AUTH_test/gettest/b_10 '
|
|
'etag: 82136b4240d6ce4ea7d03e51469a393b != '
|
|
'82136b4240d6ce4ea7d03e51469a393b or 10 != 999999.'
|
|
])
|
|
# we don't drain the segment's resp_iter if validation fails
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get')] = 1
|
|
|
|
def test_mismatched_checksum(self):
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/a_5',
|
|
swob.HTTPOk, {'Content-Length': '5',
|
|
'Etag': md5hex('a' * 5)},
|
|
# this segment has invalid content
|
|
'x' * 5)
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/gettest/b_10', 'hash': md5hex('b' * 10),
|
|
'content_type': 'text/plain', 'bytes': '10'},
|
|
{'name': '/gettest/a_5', 'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15),
|
|
'content_type': 'text/plain', 'bytes': '15'}]))
|
|
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, (b'b' * 10 + b'x' * 5))
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'Bad MD5 checksum for /v1/AUTH_test/gettest/a_5 as part of '
|
|
'/v1/AUTH_test/gettest/manifest: headers had '
|
|
'594f803b380a41396ed63dca39503542, but object MD5 was '
|
|
'actually fb0e22c79ac75679e9881e6ba183b354',
|
|
])
|
|
|
|
def test_mismatched_length(self):
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/a_5',
|
|
swob.HTTPOk, {'Content-Length': '5',
|
|
'Etag': md5hex('a' * 5)},
|
|
# this segment comes up short
|
|
[b'a' * 4])
|
|
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/gettest/b_10', 'hash': md5hex('b' * 10),
|
|
'content_type': 'text/plain', 'bytes': '10'},
|
|
{'name': '/gettest/a_5', 'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain', 'bytes': '5'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15),
|
|
'content_type': 'text/plain', 'bytes': '15'}]))
|
|
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, (b'b' * 10 + b'a' * 4))
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'Bad response length for /v1/AUTH_test/gettest/a_5 as part of '
|
|
'/v1/AUTH_test/gettest/manifest: headers had 5, but '
|
|
'response length was actually 4',
|
|
])
|
|
|
|
def test_first_segment_mismatched_etag(self):
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-badetag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('409 Conflict', status)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertIn(b'There was a conflict', body)
|
|
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'Object segment no longer valid: /v1/AUTH_test/gettest/a_5 '
|
|
'etag: 594f803b380a41396ed63dca39503542 != wrong! or 5 != 5.'
|
|
])
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-badetag'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# we don't drain the segment's resp_iter if validation fails
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get')] = 1
|
|
|
|
def test_head_does_not_validate_first_segment_mismatched_etag(self):
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-badetag',
|
|
method='HEAD')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_badetag_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_badetag_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_badetag_slo_size)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-badetag'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
expected_calls.append(
|
|
('GET', '/v1/AUTH_test/gettest/manifest-badetag'))
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_first_segment_mismatched_size(self):
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-badsize',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('409 Conflict', status)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertIn(b'There was a conflict', body)
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'Object segment no longer valid: /v1/AUTH_test/gettest/a_5 '
|
|
'etag: 594f803b380a41396ed63dca39503542 != '
|
|
'594f803b380a41396ed63dca39503542 or 5 != 999999.'
|
|
])
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-badsize'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# we don't drain the segment's resp_iter if validation fails
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get')] = 1
|
|
|
|
def test_head_does_not_validate_first_segment_mismatched_size(self):
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-badsize',
|
|
method='HEAD')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_badsize_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_badsize_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_badsize_slo_size)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-badsize'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
expected_calls.append(
|
|
('GET', '/v1/AUTH_test/gettest/manifest-badsize'))
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
@patch('swift.common.request_helpers.time')
|
|
def test_download_takes_too_long(self, mock_time):
|
|
mock_time.time.side_effect = [
|
|
0, # start time
|
|
10 * 3600, # a_5
|
|
20 * 3600, # b_10
|
|
30 * 3600, # c_15, but then we time out
|
|
]
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'While processing manifest /v1/AUTH_test/gettest/manifest-abcd, '
|
|
'max LO GET time of 86400s exceeded'
|
|
])
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')])
|
|
# we timeout without reading the whole of the last segment
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')] = 1
|
|
|
|
def test_first_segment_not_exists(self):
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/not_exists_obj',
|
|
swob.HTTPNotFound, {}, None)
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/manifest-not-exists',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/gettest/not_exists_obj',
|
|
'hash': md5hex('not_exists_obj'),
|
|
'content_type': 'text/plain',
|
|
'bytes': '%d' % len('not_exists_obj')
|
|
}]))
|
|
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-not-exists',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('409 Conflict', status)
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'While processing manifest /v1/AUTH_test/gettest/'
|
|
'manifest-not-exists, got 404 (<html><h1>Not Found</h1><p>The '
|
|
'resource could not be foun...) while retrieving /v1/AUTH_test/'
|
|
'gettest/not_exists_obj'
|
|
])
|
|
|
|
def test_first_segment_not_available(self):
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/not_avail_obj',
|
|
swob.HTTPServiceUnavailable, {}, None)
|
|
self.app.register('GET', '/v1/AUTH_test/gettest/manifest-not-avail',
|
|
swob.HTTPOk, {'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'},
|
|
json.dumps([{'name': '/gettest/not_avail_obj',
|
|
'hash': md5hex('not_avail_obj'),
|
|
'content_type': 'text/plain',
|
|
'bytes': '%d' % len('not_avail_obj')
|
|
}]))
|
|
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-not-avail',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('503 Service Unavailable', status)
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [
|
|
'While processing manifest /v1/AUTH_test/gettest/'
|
|
'manifest-not-avail, got 503 (<html><h1>Service Unavailable</h1>'
|
|
'<p>The server is curren...) while retrieving /v1/AUTH_test/'
|
|
'gettest/not_avail_obj'
|
|
])
|
|
self.assertIn(b'Service Unavailable', body)
|
|
|
|
|
|
class TestSloErrorsOldManifests(TestSloErrors):
|
|
|
|
modern_manifest_headers = False
|
|
|
|
|
|
class TestSloDataSegments(SloGETorHEADTestCase):
|
|
# data segments were added some months after modern slo-sysmeta
|
|
|
|
def setUp(self):
|
|
super(TestSloDataSegments, self).setUp()
|
|
self._setup_alphabet_objects('ab')
|
|
|
|
def test_leading_data_segment(self):
|
|
slo_etag = md5hex(
|
|
md5hex('preamble') +
|
|
md5hex('a' * 5)
|
|
)
|
|
preamble = base64.b64encode(b'preamble')
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-single-preamble',
|
|
swob.HTTPOk,
|
|
{
|
|
'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'
|
|
},
|
|
json.dumps([{
|
|
'data': preamble.decode('ascii')
|
|
}, {
|
|
'name': '/gettest/a_5',
|
|
'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain',
|
|
'bytes': '5',
|
|
}])
|
|
)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-preamble',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, b'preambleaaaaa')
|
|
self.assertEqual(headers['Etag'], '"%s"' % slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '13')
|
|
|
|
def test_trailing_data_segment(self):
|
|
slo_etag = md5hex(
|
|
md5hex('a' * 5) +
|
|
md5hex('postamble')
|
|
)
|
|
postamble = base64.b64encode(b'postamble')
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-single-postamble',
|
|
swob.HTTPOk,
|
|
{
|
|
'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'
|
|
},
|
|
json.dumps([{
|
|
'name': '/gettest/a_5',
|
|
'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain',
|
|
'bytes': '5',
|
|
}, {
|
|
'data': postamble.decode('ascii')
|
|
}]).encode('ascii')
|
|
)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-postamble',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, b'aaaaapostamble')
|
|
self.assertEqual(headers['Etag'], '"%s"' % slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '14')
|
|
|
|
def test_data_segment_sandwich(self):
|
|
slo_etag = md5hex(
|
|
md5hex('preamble') +
|
|
md5hex('a' * 5) +
|
|
md5hex('postamble')
|
|
)
|
|
preamble = base64.b64encode(b'preamble')
|
|
postamble = base64.b64encode(b'postamble')
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
swob.HTTPOk,
|
|
{
|
|
'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'
|
|
},
|
|
json.dumps([{
|
|
'data': preamble.decode('ascii'),
|
|
}, {
|
|
'name': '/gettest/a_5',
|
|
'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain',
|
|
'bytes': '5',
|
|
}, {
|
|
'data': postamble.decode('ascii')
|
|
}])
|
|
)
|
|
|
|
# Test the whole SLO
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, b'preambleaaaaapostamble')
|
|
self.assertEqual(headers['Etag'], '"%s"' % slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '22')
|
|
|
|
# Test complete preamble only
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=0-7'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'preamble')
|
|
|
|
# Test range within preamble only
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=1-5'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'reamb')
|
|
|
|
# Test complete postamble only
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=13-21'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'postamble')
|
|
|
|
# Test partial pre and postamble
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=4-16'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'mbleaaaaapost')
|
|
|
|
# Test partial preamble and first byte of data
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=1-8'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'reamblea')
|
|
|
|
# Test last byte of segment data and partial postamble
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=12-16'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'apost')
|
|
|
|
def test_bunches_of_data_segments(self):
|
|
slo_etag = md5hex(
|
|
md5hex('ABCDEF') +
|
|
md5hex('a' * 5) +
|
|
md5hex('123456') +
|
|
md5hex('GHIJKL') +
|
|
md5hex('b' * 10) +
|
|
md5hex('7890@#')
|
|
)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/manifest-multi-prepostamble',
|
|
swob.HTTPOk,
|
|
{
|
|
'Content-Type': 'application/json',
|
|
'X-Static-Large-Object': 'true'
|
|
},
|
|
json.dumps([
|
|
{
|
|
'data': base64.b64encode(b'ABCDEF').decode('ascii')
|
|
},
|
|
{
|
|
'name': '/gettest/a_5',
|
|
'hash': md5hex('a' * 5),
|
|
'content_type': 'text/plain',
|
|
'bytes': '5',
|
|
},
|
|
{
|
|
'data': base64.b64encode(b'123456').decode('ascii')
|
|
},
|
|
{
|
|
'data': base64.b64encode(b'GHIJKL').decode('ascii')
|
|
},
|
|
{
|
|
'name': '/gettest/b_10',
|
|
'hash': md5hex('b' * 10),
|
|
'content_type': 'text/plain',
|
|
'bytes': '10',
|
|
},
|
|
{
|
|
'data': base64.b64encode(b'7890@#').decode('ascii')
|
|
}
|
|
])
|
|
)
|
|
|
|
# Test the whole SLO
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-multi-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('200 OK', status)
|
|
self.assertEqual(body, b'ABCDEFaaaaa123456GHIJKLbbbbbbbbbb7890@#')
|
|
self.assertEqual(headers['Etag'], '"%s"' % slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '39')
|
|
|
|
# Test last byte first pre-amble to first byte of second postamble
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-multi-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=5-33'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'Faaaaa123456GHIJKLbbbbbbbbbb7')
|
|
|
|
# Test only second complete preamble
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-multi-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=17-22'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'GHIJKL')
|
|
|
|
# Test only first complete postamble
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-multi-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=11-16'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'123456')
|
|
|
|
# Test only range within first postamble
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-multi-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=12-15'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'2345')
|
|
|
|
# Test only range within first postamble and second preamble
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-multi-prepostamble',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=12-18'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual('206 Partial Content', status)
|
|
self.assertEqual(body, b'23456GH')
|
|
|
|
|
|
class TestSloConditionalGetOldManifest(SloGETorHEADTestCase):
|
|
|
|
modern_manifest_headers = False
|
|
|
|
def setUp(self):
|
|
super(TestSloConditionalGetOldManifest, self).setUp()
|
|
self._setup_alphabet_objects('abcd')
|
|
self._setup_manifest_bc()
|
|
self._setup_manifest_abcd()
|
|
|
|
# plain object with alt-etag
|
|
num_segments = 2
|
|
alt_seg_info = []
|
|
for i in range(num_segments):
|
|
body = (b'alt_%02d' % i) * 5
|
|
etag = md5hex(body)
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/c/alt_%02d' % i,
|
|
swob.HTTPOk, {
|
|
'Content-Length': len(body),
|
|
'Etag': etag,
|
|
'X-Object-Sysmeta-Alt-Etag': 'seg-etag-%02d' % i
|
|
}, body=body)
|
|
alt_seg_info.append((body, etag))
|
|
|
|
# s3api is to the left of SLO and writes an alternate etag for
|
|
# conditional requests to match
|
|
self._setup_manifest('alt', [{
|
|
'name': '/c/alt_%02d' % i,
|
|
'bytes': len(body),
|
|
'hash': etag,
|
|
'content_type': 'text/plain',
|
|
} for i, (body, etag) in enumerate(alt_seg_info)], extra_headers={
|
|
'X-Object-Sysmeta-Alt-Etag': '"alt-etag-1"',
|
|
})
|
|
|
|
self._setup_manifest('last-modified', [
|
|
{'name': '/gettest/a_5', 'hash': md5hex('a' * 5), 'bytes': '5',
|
|
'content_type': 'text/plain'},
|
|
{'name': '/gettest/c_15', 'hash': md5hex('c' * 15), 'bytes': '15',
|
|
'content_type': 'text/plain'},
|
|
], extra_headers={
|
|
'Last-Modified': 'Mon, 23 Oct 2023 10:05:32 GMT',
|
|
})
|
|
|
|
def test_if_none_match_matches(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
headers={'If-None-Match': self.manifest_abcd_slo_etag})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '304 Not Modified')
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/gettest/manifest-abcd')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.extend([
|
|
# N.B. since manifest didn't match slo_etag => no refetch
|
|
# TODO: skip coalecse until validate to avoid sub-manifest req
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
# for legacy manifests we don't know if swob will return a
|
|
# successful response or conditional error so we validate the
|
|
# first segment to avoid a 2XX when we should 5XX
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# when swob decides to error it closes our SegmentedIterable
|
|
# and we don't drain the (possibly large) segment.
|
|
self.expected_unread_requests[('GET', '/v1/AUTH_test/gettest/a_5'
|
|
'?multipart-manifest=get')] = 1
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
if not self.modern_manifest_headers:
|
|
for headers in self.app.headers[1:]:
|
|
self.assertNotIn('If-Match', headers)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', headers)
|
|
|
|
def test_if_none_match_mismatches(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
headers={'If-None-Match': "not-%s" % self.manifest_abcd_slo_etag})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
self.assertEqual('50', headers['Content-Length'])
|
|
self.assertEqual(
|
|
body, b'aaaaabbbbbbbbbbcccccccccccccccdddddddddddddddddddd')
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
|
|
def test_if_none_match_mismatches_json_md5(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
headers={'If-None-Match': self.manifest_abcd_json_md5})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
self.assertEqual('50', headers['Content-Length'])
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
# w/o modern manifest headers, the json manifest etag responds 304
|
|
# and triggers a refetch!
|
|
expected_app_calls.append(
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd')
|
|
)
|
|
expected_app_calls.extend([
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get'),
|
|
])
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_none_match_matches_alternate_etag(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt',
|
|
headers={'If-None-Match': '"alt-etag-1"'})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '304 Not Modified')
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual('"%s"' % self.manifest_alt_slo_etag, headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('"alt-etag-1"', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/c/manifest-alt')]
|
|
self.assertEqual(
|
|
self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'X-Object-Sysmeta-Alt-Etag,x-object-sysmeta-slo-etag')
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.extend([
|
|
# Needed to re-fetch because if-match can't find slo-etag, and
|
|
# has to 304
|
|
('GET', '/v1/AUTH_test/c/manifest-alt'),
|
|
# for legacy manifests we don't know if swob will return a
|
|
# successful response or conditional error so we validate the
|
|
# first segment to avoid a 2XX when we should 5XX
|
|
('GET', '/v1/AUTH_test/c/alt_00?multipart-manifest=get'),
|
|
])
|
|
# when swob decides to error it closes our SegmentedIterable
|
|
# and we don't drain the (possibly large) segment.
|
|
self.expected_unread_requests[('GET', '/v1/AUTH_test/c/alt_00'
|
|
'?multipart-manifest=get')] = 1
|
|
for headers in self.app.headers[1:]:
|
|
self.assertNotIn('If-None-Match', headers)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', headers)
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_none_match_matches_no_alternate_etag(self):
|
|
# this is similar to test_if_none_match_matches, but serves as a sanity
|
|
# check to test_if_none_match_mismatches_alternate_etag, which appends
|
|
# to etag-is-at
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt',
|
|
headers={'If-None-Match': self.manifest_alt_slo_etag})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '304 Not Modified')
|
|
self.assertEqual('"%s"' % self.manifest_alt_slo_etag, headers['Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/c/manifest-alt')]
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.extend([
|
|
# N.B. since manifest didn't match slo_etag => no refetch
|
|
# for legacy manifests we don't know if swob will return a
|
|
# successful response or conditional error so we validate the
|
|
# first segment to avoid a 2XX when we should 5XX
|
|
('GET', '/v1/AUTH_test/c/alt_00?multipart-manifest=get'),
|
|
])
|
|
for headers in self.app.headers[1:]:
|
|
self.assertNotIn('If-Match', headers)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', headers)
|
|
# for legacy manifests we don't know if swob will return a
|
|
# successful response or conditional error so we validate the first
|
|
# segment to avoid a 2XX when we should 5XX, when swob decides to
|
|
# error it closes our SegmentedIterable and we don't drain the
|
|
# (possibly large) segment.
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/c/alt_00'
|
|
'?multipart-manifest=get')] = 1
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_none_match_mismatches_alternate_etag(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt',
|
|
headers={'If-None-Match': self.manifest_alt_slo_etag})
|
|
# N.B. SLO request with if-none-match slo_etag would normally respond
|
|
# not modified (see test_if_none_match_matches_no_alternate_etag), but
|
|
# here we provide alt-tag so it doesn't match so the request is success
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual('"%s"' % self.manifest_alt_slo_etag, headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('"alt-etag-1"', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
self.assertEqual(self.manifest_alt_slo_size,
|
|
int(headers['Content-Length']))
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-alt'),
|
|
('GET', '/v1/AUTH_test/c/alt_00?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/c/alt_01?multipart-manifest=get'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(
|
|
self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'X-Object-Sysmeta-Alt-Etag,x-object-sysmeta-slo-etag')
|
|
for headers in self.app.headers[1:]:
|
|
self.assertNotIn('If-None-Match', headers)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', headers)
|
|
|
|
def test_if_match_matches(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'If-Match': self.manifest_abcd_slo_etag})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
self.assertEqual('50', headers['Content-Length'])
|
|
self.assertEqual(
|
|
body, b'aaaaabbbbbbbbbbcccccccccccccccdddddddddddddddddddd')
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/gettest/manifest-abcd')]
|
|
if not self.modern_manifest_headers:
|
|
# Manifest never matches -> got back a 412; need to re-fetch
|
|
expected_app_calls.append(
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'))
|
|
expected_app_calls.extend([
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get'),
|
|
])
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
|
|
def test_if_match_mismatches(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
headers={'If-Match': 'not-%s' % self.manifest_abcd_json_md5})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/gettest/manifest-abcd')]
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.extend([
|
|
# Manifest "never" matches -> got back a 412; need to re-fetch
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
# TODO: skip coalecse until validate to avoid sub-manifest req
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
# for legacy manifests we don't know if swob will return a
|
|
# successful response or conditional error so we validate the
|
|
# first segment to avoid a 2XX when we should 5XX
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# when swob decides to error it closes our SegmentedIterable
|
|
# and we don't drain the (possibly large) segment.
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5'
|
|
'?multipart-manifest=get')] = 1
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
if not self.modern_manifest_headers:
|
|
for headers in self.app.headers[1:]:
|
|
self.assertNotIn('If-Match', headers)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', headers)
|
|
|
|
def test_if_match_mismatches_manifest_json_md5(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
headers={'If-Match': self.manifest_abcd_json_md5})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
# 412 is always zero-bytes because client is trying to save egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(body, b'')
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/gettest/manifest-abcd')]
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
if not self.modern_manifest_headers:
|
|
# We *still* verify the first segment
|
|
expected_app_calls.extend([
|
|
# N.B. since manifest matched => no refetch
|
|
# TODO: skip coalecse until validate to avoid sub-manifest req
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# swob conditional errors throw away the SegmentedIterable w/o
|
|
# reading the remaining segments
|
|
self.expected_unread_requests[('GET', '/v1/AUTH_test/gettest/a_5'
|
|
'?multipart-manifest=get')] = 1
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_match_matches_alternate_etag(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt',
|
|
headers={'If-Match': '"alt-etag-1"'})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(self.manifest_alt_slo_size,
|
|
int(headers['Content-Length']))
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual('"%s"' % self.manifest_alt_slo_etag, headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('"alt-etag-1"', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-alt'),
|
|
('GET', '/v1/AUTH_test/c/alt_00?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/c/alt_01?multipart-manifest=get'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(
|
|
self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'X-Object-Sysmeta-Alt-Etag,x-object-sysmeta-slo-etag')
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[1])
|
|
|
|
def test_if_match_mismatches_alternate_etag(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt',
|
|
headers={'If-Match': self.manifest_alt_slo_etag})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual('"%s"' % self.manifest_alt_slo_etag, headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('"alt-etag-1"', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/c/manifest-alt')]
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'X-Object-Sysmeta-Alt-Etag,x-object-sysmeta-slo-etag')
|
|
if not self.modern_manifest_headers:
|
|
expected_app_calls.extend([
|
|
# Needed to re-fetch because if-match can't find slo-etag
|
|
('GET', '/v1/AUTH_test/c/manifest-alt'),
|
|
# We end up validating the first segment
|
|
('GET', '/v1/AUTH_test/c/alt_00?multipart-manifest=get'),
|
|
])
|
|
# for legacy manifests we don't know if swob will return a
|
|
# successful response or conditional error so we validate the first
|
|
# segment to avoid a 2XX when we should 5XX, when swob decides to
|
|
# error it closes our SegmentedIterable and we don't drain the
|
|
# (possibly large) segment.
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/c/alt_00'
|
|
'?multipart-manifest=get')] = 1
|
|
for headers in self.app.headers[1:]:
|
|
self.assertNotIn('If-Match', headers)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', headers)
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_manifest_get_if_none_match_matches(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd?multipart-manifest=get',
|
|
headers={'If-None-Match': self.manifest_abcd_json_md5})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '304 Not Modified')
|
|
self.assertEqual(self.manifest_abcd_json_md5, headers['Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/gettest/manifest-abcd'
|
|
'?multipart-manifest=get')]
|
|
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[0])
|
|
|
|
def test_manifest_get_if_none_match_mismatches(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd?multipart-manifest=get',
|
|
headers={'If-None-Match': "not-%s" % self.manifest_abcd_json_md5})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(self.manifest_abcd_json_md5, headers['Etag'])
|
|
self.assertEqual(self.manifest_abcd_json_size,
|
|
int(headers['Content-Length']))
|
|
data = json.loads(body)
|
|
self.assertEqual(
|
|
['/gettest/a_5', '/gettest/manifest-bc', '/gettest/d_20'],
|
|
[s['name'] for s in data])
|
|
self.assertEqual(md5hex(body), self.manifest_abcd_json_md5)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/gettest/manifest-abcd'
|
|
'?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[0])
|
|
|
|
def test_manifest_get_if_none_match_matches_alternate_etag(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt?multipart-manifest=get',
|
|
headers={'If-None-Match': '"alt-etag-1"'})
|
|
# who would do this for a multipart-manifest=get requests?
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '304 Not Modified')
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual(self.manifest_alt_json_md5, headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('"alt-etag-1"', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/c/manifest-alt'
|
|
'?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(
|
|
self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'X-Object-Sysmeta-Alt-Etag')
|
|
|
|
def test_manifest_get_if_none_match_mismatches_alternate_etag(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt?multipart-manifest=get',
|
|
headers={'If-None-Match': '"not-alt-etag-1"'})
|
|
# who would do this for a multipart-manifest=get requests?
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual(self.manifest_alt_json_md5, headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('"alt-etag-1"', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
data = json.loads(body)
|
|
self.assertEqual(['/c/alt_%02d' % i for i in range(len(data))],
|
|
[s['name'] for s in data])
|
|
self.assertEqual(md5hex(body), self.manifest_alt_json_md5)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/c/manifest-alt'
|
|
'?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(
|
|
self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'X-Object-Sysmeta-Alt-Etag')
|
|
|
|
def test_manifest_get_if_match_matches(self):
|
|
# use if-match condition and expect to match
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd?multipart-manifest=get',
|
|
headers={'If-Match': self.manifest_abcd_json_md5})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(self.manifest_abcd_json_md5, headers['Etag'])
|
|
expected_app_calls = [
|
|
('GET',
|
|
'/v1/AUTH_test/gettest/manifest-abcd?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[0])
|
|
|
|
def test_manifest_get_if_match_mismatches(self):
|
|
# use if-match condition and expect to mismatch
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd?multipart-manifest=get',
|
|
headers={'If-Match': self.manifest_abcd_slo_etag})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
self.assertEqual(self.manifest_abcd_json_md5, headers['Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [
|
|
('GET',
|
|
'/v1/AUTH_test/gettest/manifest-abcd?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[0])
|
|
|
|
def test_manifest_get_if_match_matches_alternate_etag(self):
|
|
# use if-match condition with alt-etag and expect to match
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt?multipart-manifest=get',
|
|
headers={'If-Match': '"alt-etag-1"'})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(self.manifest_alt_json_md5, headers['Etag'])
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-alt?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual('X-Object-Sysmeta-Alt-Etag',
|
|
self.app.headers[0]['X-Backend-Etag-Is-At'])
|
|
|
|
def test_manifest_get_if_match_mismatches_alternate_etag(self):
|
|
# mis-match alternate etag
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt?multipart-manifest=get',
|
|
headers={'If-Match': self.manifest_alt_json_md5})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
self.assertEqual(self.manifest_alt_json_md5, headers['Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-alt?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual('X-Object-Sysmeta-Alt-Etag',
|
|
self.app.headers[0].get('X-Backend-Etag-Is-At'))
|
|
|
|
def test_manifest_get_if_match_mismatches_without_alternate_etag(self):
|
|
# sanity, this is similar to the test_manifest_get_if_match_mismatches
|
|
# but in this case our manifest *has* an alt-etag, but no-one tells the
|
|
# object server to look for it
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt?multipart-manifest=get',
|
|
headers={'If-Match': '"alt-etag-1"'})
|
|
# update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
self.assertEqual(self.manifest_alt_json_md5, headers['Etag'])
|
|
# conditional errors are always zero-bytes to save client egress
|
|
self.assertEqual('0', headers['Content-Length'])
|
|
self.assertEqual(b'', body)
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-alt?multipart-manifest=get')]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[0])
|
|
|
|
def test_manifest_get_if_match_mismatches_alternate_etag_miss(self):
|
|
# sanity, this is similar to
|
|
# test_manifest_get_if_match_mismatches_alternate_etag but in this case
|
|
# our manifest doesn't HAVE an alt-etag, so the object server falls
|
|
# back to match with manifest's json_md5
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd?multipart-manifest=get',
|
|
headers={'If-Match': self.manifest_abcd_json_md5})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
|
|
def test_if_match_matches_alternate_etag_non_slo(self):
|
|
# match alternate etag
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/alt_00',
|
|
headers={'If-Match': 'seg-etag-00'})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual(md5hex('alt_00' * 5), headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('seg-etag-00', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/c/alt_00'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_if_match_mismatches_alternate_etag_non_slo(self):
|
|
# mis-match alternate etag
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/alt_00',
|
|
headers={'If-Match': md5hex(b'alt_00' * 5)})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
# N.B. Etag-Is-At only effects conditional matching, not response Etag
|
|
self.assertEqual(md5hex('alt_00' * 5), headers['Etag'])
|
|
# ... but the response Sysmeta will be available to wrapping middleware
|
|
self.assertEqual('seg-etag-00', headers['X-Object-Sysmeta-Alt-Etag'])
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/c/alt_00'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_if_match_matches_alternate_etag_non_slo_after_refetch(self):
|
|
self.app.register_next_response(
|
|
'GET', '/v1/AUTH_test/c/manifest-alt',
|
|
swob.HTTPOk, {'Content-Length': '25',
|
|
'Etag': md5hex('alt_1' * 5),
|
|
# N.B. manifest-alt gets overwritten mid-flight!
|
|
'X-Backend-Timestamp': '2345',
|
|
'X-Object-Sysmeta-Alt-Etag': 'alt-object-etag'},
|
|
body=b'alt_1' * 5)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt',
|
|
headers={'If-Match': 'alt-object-etag'})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/c/manifest-alt')]
|
|
# first request asks for match on alt-etag
|
|
self.assertEqual('alt-object-etag', self.app.headers[0]['If-Match'])
|
|
self.assertIn('X-Object-Sysmeta-Alt-Etag',
|
|
self.app.headers[0]['X-Backend-Etag-Is-At'])
|
|
|
|
if self.modern_manifest_headers:
|
|
# and since the response includes modern sysmeta, slo trusts the
|
|
# 412 w/o refetch
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
# N.B. if the first repsonse had included a matching
|
|
# alt-object-etag in sysmeta we would have returned 200, see
|
|
# test_if_match_matches_alternate_etag with "alt-etag-1"
|
|
self.assertEqual('"%s"' % self.manifest_alt_slo_etag,
|
|
headers['Etag'])
|
|
self.assertEqual('"alt-etag-1"',
|
|
headers['X-Object-Sysmeta-Alt-Etag'])
|
|
else:
|
|
# ... but lacking modern sysmeta, slo will refetch a 412
|
|
expected_app_calls.append(
|
|
('GET', '/v1/AUTH_test/c/manifest-alt')
|
|
)
|
|
# ... w/o conditionals
|
|
self.assertNotIn('If-Match', self.app.headers[1])
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[1])
|
|
# and the reconstructed swob response will *match*
|
|
self.assertEqual(status, '200 OK')
|
|
# N.B. Etag-Is-At only effects conditional matching,
|
|
# not response Etag
|
|
self.assertEqual(md5hex('alt_1' * 5), headers['Etag'])
|
|
# ... but the response Sysmeta will be available to
|
|
# wrapping middleware
|
|
self.assertEqual('alt-object-etag',
|
|
headers['X-Object-Sysmeta-Alt-Etag'])
|
|
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_match_mismatches_alternate_etag_non_slo_after_refetch(self):
|
|
self.app.register_next_response(
|
|
'GET', '/v1/AUTH_test/c/manifest-alt',
|
|
swob.HTTPOk, {'Content-Length': '25',
|
|
'Etag': md5hex('alt_1' * 5),
|
|
# N.B. manifest-alt gets overwritten mid-flight!
|
|
'X-Backend-Timestamp': '2345',
|
|
'X-Object-Sysmeta-Alt-Etag': 'alt-object-etag'},
|
|
body=b'alt_1' * 5)
|
|
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-alt',
|
|
headers={'If-Match': md5hex('alt_1' * 5)})
|
|
update_etag_is_at_header(req, 'X-Object-Sysmeta-Alt-Etag')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
expected_app_calls = [('GET', '/v1/AUTH_test/c/manifest-alt')]
|
|
# first request asks for (mis)match on alt-etag
|
|
self.assertEqual(md5hex('alt_1' * 5), self.app.headers[0]['If-Match'])
|
|
self.assertIn('X-Object-Sysmeta-Alt-Etag',
|
|
self.app.headers[0]['X-Backend-Etag-Is-At'])
|
|
|
|
if self.modern_manifest_headers:
|
|
# and since the response includes modern sysmeta, slo trusts the
|
|
# 412 w/o refetch
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
# N.B. the first repsonse included an alt-etag in sysmeta (i.e.
|
|
# "alt-etag-1"), it just doesn't match either - see
|
|
# test_if_match_mismatches_alternate_etag
|
|
self.assertEqual('"%s"' % self.manifest_alt_slo_etag,
|
|
headers['Etag'])
|
|
self.assertEqual('"alt-etag-1"',
|
|
headers['X-Object-Sysmeta-Alt-Etag'])
|
|
else:
|
|
# ... but lacking modern sysmeta, slo will refetch a 412
|
|
expected_app_calls.append(
|
|
('GET', '/v1/AUTH_test/c/manifest-alt')
|
|
)
|
|
# ... w/o conditionals
|
|
self.assertNotIn('If-Match', self.app.headers[1])
|
|
self.assertNotIn('X-Backend-Etag-Is-At', self.app.headers[1])
|
|
# and the reconstructed swob response will *not* match
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
# N.B. Etag-Is-At only effects conditional matching,
|
|
# not response Etag
|
|
self.assertEqual(md5hex('alt_1' * 5), headers['Etag'])
|
|
# ... but the response Sysmeta will be available to
|
|
# wrapping middleware
|
|
self.assertEqual('alt-object-etag',
|
|
headers['X-Object-Sysmeta-Alt-Etag'])
|
|
# swob is converting the successful non-slo response to conditional
|
|
# error and closing our unconditionally refetched resp_iter
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/c/manifest-alt')] = 1
|
|
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_if_match_matches_and_range(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'If-Match': self.manifest_abcd_slo_etag,
|
|
'Range': 'bytes=3-6'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertIn('bytes 3-6/50', headers['Content-Range'])
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
self.assertEqual('4', headers['Content-Length'])
|
|
self.assertEqual(body, b'aabb')
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
# Needed to re-fetch because if-match can't find slo-etag
|
|
expected_app_calls.append(
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
)
|
|
# and then fetch the segments
|
|
expected_app_calls.extend([
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
])
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
|
|
def test_old_swift_if_match_matches_and_range(self):
|
|
self.app.can_ignore_range = False
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
headers={'If-Match': self.manifest_abcd_slo_etag,
|
|
'Range': 'bytes=3-6'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual('"%s"' % self.manifest_abcd_slo_etag, headers['Etag'])
|
|
self.assertEqual('4', headers['Content-Length'])
|
|
self.assertEqual(body, b'aabb')
|
|
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
# new-sytle manifest sysmeta was added 2016, but ignore-range
|
|
# didn't get added until 2020, so both new and old manifest
|
|
# will still require refetch with old-swift
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
self.assertEqual(self.app.headers[0].get('X-Backend-Etag-Is-At'),
|
|
'x-object-sysmeta-slo-etag')
|
|
|
|
def test_range_resume_download(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'Range': 'bytes=20-'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(body, b'ccccccccccdddddddddddddddddddd')
|
|
|
|
def test_get_with_if_modified_since(self):
|
|
req = swob.Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd',
|
|
environ={'REQUEST_METHOD': 'GET'},
|
|
headers={'If-Modified-Since': 'Wed, 12 Feb 2014 22:24:52 GMT',
|
|
'If-Unmodified-Since': 'Thu, 13 Feb 2014 23:25:53 GMT'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_abcd_slo_size)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(self.slo.logger.get_lines_for_level('error'), [])
|
|
|
|
self.assertEqual(
|
|
self.app.calls,
|
|
[('GET', '/v1/AUTH_test/gettest/manifest-abcd'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')])
|
|
|
|
# It's important not to pass the If-[Un]Modified-Since header to the
|
|
# proxy for segment or submanifest GET requests, as it may result in
|
|
# 304 Not Modified responses, and those don't contain any useful data.
|
|
for _, _, hdrs in self.app.calls_with_headers[1:]:
|
|
self.assertNotIn('If-Modified-Since', hdrs)
|
|
self.assertNotIn('If-Unmodified-Since', hdrs)
|
|
|
|
def test_if_modified_since_ancient_date(self):
|
|
req = swob.Request.blank(
|
|
'/v1/AUTH_test/c/manifest-last-modified',
|
|
headers={
|
|
'If-Modified-Since': 'Fri, 01 Feb 2012 20:38:36 GMT',
|
|
})
|
|
status, headers, body = self.call_slo(req)
|
|
# oh it's *definately* been modified since then!
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_last_modified_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_last_modified_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_last_modified_slo_size)
|
|
self.assertEqual(headers['Last-Modified'],
|
|
'Mon, 23 Oct 2023 10:05:32 GMT')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
])
|
|
|
|
def test_if_modified_since_last_modified(self):
|
|
req = swob.Request.blank(
|
|
'/v1/AUTH_test/c/manifest-last-modified',
|
|
headers={
|
|
'If-Modified-Since': 'Mon, 23 Oct 2023 10:05:32 GMT',
|
|
})
|
|
status, headers, body = self.call_slo(req)
|
|
# nope, that was the last time it was changed
|
|
self.assertEqual(status, '304 Not Modified')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_last_modified_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_last_modified_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual('Mon, 23 Oct 2023 10:05:32 GMT',
|
|
headers['Last-Modified'])
|
|
self.assertEqual(b'', body)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
# N.B. legacy manifests must refetch for accurate Etag, and then we
|
|
# validate first segment before lettting swob return the error
|
|
expected_calls.extend([
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# we don't drain the segment's resp_iter if validation fails
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5'
|
|
'?multipart-manifest=get')] = 1
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_if_modified_since_now(self):
|
|
now = datetime.now()
|
|
last_modified = now.strftime("%a, %d %b %Y %H:%M:%S %Z")
|
|
req = swob.Request.blank(
|
|
'/v1/AUTH_test/c/manifest-last-modified',
|
|
headers={
|
|
'If-Modified-Since': last_modified,
|
|
})
|
|
status, headers, body = self.call_slo(req)
|
|
# nope, that was the last time it was changed
|
|
self.assertEqual(status, '304 Not Modified')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_last_modified_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_last_modified_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual('Mon, 23 Oct 2023 10:05:32 GMT',
|
|
headers['Last-Modified'])
|
|
self.assertEqual(b'', body)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
# N.B. legacy manifests must refetch for accurate Etag, and then we
|
|
# validate first segment before lettting swob return the error
|
|
expected_calls.extend([
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# we don't drain the segment's resp_iter if validation fails
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5'
|
|
'?multipart-manifest=get')] = 1
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_if_unmodified_since_ancient_date(self):
|
|
req = swob.Request.blank(
|
|
'/v1/AUTH_test/c/manifest-last-modified',
|
|
headers={
|
|
'If-Unmodified-Since': 'Fri, 01 Feb 2012 20:38:36 GMT',
|
|
})
|
|
status, headers, body = self.call_slo(req)
|
|
# oh it's *definately* been modified since then!
|
|
self.assertEqual(status, '412 Precondition Failed')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_last_modified_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_last_modified_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '0')
|
|
self.assertEqual('Mon, 23 Oct 2023 10:05:32 GMT',
|
|
headers['Last-Modified'])
|
|
self.assertEqual(b'', body)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
]
|
|
if not self.modern_manifest_headers:
|
|
# N.B. legacy manifests must refetch for accurate Etag, and then we
|
|
# validate first segment before lettting swob return the error
|
|
expected_calls.extend([
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
])
|
|
# we don't drain the segment's resp_iter if validation fails
|
|
self.expected_unread_requests[
|
|
('GET', '/v1/AUTH_test/gettest/a_5'
|
|
'?multipart-manifest=get')] = 1
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_if_unmodified_since_last_modified(self):
|
|
req = swob.Request.blank(
|
|
'/v1/AUTH_test/c/manifest-last-modified',
|
|
headers={
|
|
'If-Unmodified-Since': 'Mon, 23 Oct 2023 10:05:32 GMT',
|
|
})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_last_modified_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_last_modified_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_last_modified_slo_size)
|
|
self.assertEqual(headers['Last-Modified'],
|
|
'Mon, 23 Oct 2023 10:05:32 GMT')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
])
|
|
|
|
def test_if_unmodified_since_now(self):
|
|
now = datetime.now()
|
|
last_modified = now.strftime("%a, %d %b %Y %H:%M:%S %Z")
|
|
req = swob.Request.blank(
|
|
'/v1/AUTH_test/c/manifest-last-modified',
|
|
headers={
|
|
'If-Unmodified-Since': last_modified,
|
|
})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_last_modified_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_last_modified_json_md5)
|
|
self.assertEqual(int(headers['Content-Length']),
|
|
self.manifest_last_modified_slo_size)
|
|
self.assertEqual(headers['Last-Modified'],
|
|
'Mon, 23 Oct 2023 10:05:32 GMT')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/c/manifest-last-modified'),
|
|
('GET', '/v1/AUTH_test/gettest/a_5?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
])
|
|
|
|
|
|
class TestSloConditionalGetNewManifest(TestSloConditionalGetOldManifest):
|
|
|
|
modern_manifest_headers = True
|
|
|
|
|
|
class TestPartNumber(SloGETorHEADTestCase):
|
|
|
|
modern_manifest_headers = True
|
|
|
|
def setUp(self):
|
|
super(TestPartNumber, self).setUp()
|
|
self._setup_alphabet_objects('bcdj')
|
|
self._setup_manifest_bc()
|
|
self._setup_manifest_abcd()
|
|
self._setup_manifest_abcdefghijkl()
|
|
self._setup_manifest_bc_ranges()
|
|
self._setup_manifest_abcd_ranges()
|
|
self._setup_manifest_abcd_subranges()
|
|
self._setup_manifest_aabbccdd()
|
|
self._setup_manifest_single_segment()
|
|
|
|
# this b_50 object doesn't follow the alphabet convention
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/gettest/b_50',
|
|
swob.HTTPPartialContent, {'Content-Length': '50',
|
|
'Etag': md5hex('b' * 50)},
|
|
'b' * 50)
|
|
|
|
self._setup_manifest_data()
|
|
|
|
def test_head_part_number(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=1',
|
|
environ={'REQUEST_METHOD': 'HEAD'})
|
|
status, headers, body = self.call_slo(req)
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-bc?part-number=1'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=1')
|
|
]
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_bc_slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '10')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 0-9/25')
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '2')
|
|
self.assertEqual(headers['Content-Type'], 'application/octet-stream')
|
|
self.assertEqual(body, b'') # it's a HEAD request, after all
|
|
self.assertEqual(headers['X-Object-Meta-Plant'], 'Ficus')
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_head_part_number_refetch_path(self):
|
|
# verify that any modification of the request path by a downstream
|
|
# middleware is ignored when refetching
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/mani?part-number=1',
|
|
environ={'REQUEST_METHOD': 'HEAD'})
|
|
captured_calls = []
|
|
orig_call = FakeSwift.__call__
|
|
|
|
def pseudo_middleware(app, env, start_response):
|
|
captured_calls.append((env['REQUEST_METHOD'], env['PATH_INFO']))
|
|
# pretend another middleware modified the path
|
|
# note: for convenience, the path "modification" actually results
|
|
# in one of the pre-registered paths
|
|
env['PATH_INFO'] += 'fest-bc'
|
|
return orig_call(app, env, start_response)
|
|
|
|
with patch.object(FakeSwift, '__call__', pseudo_middleware):
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
# pseudo-middleware gets the original path for the refetch
|
|
self.assertEqual([('HEAD', '/v1/AUTH_test/gettest/mani'),
|
|
('GET', '/v1/AUTH_test/gettest/mani')],
|
|
captured_calls)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
expected_calls = [
|
|
# original path is modified...
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-bc?part-number=1'),
|
|
# refetch: the *original* path is modified...
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=1')
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_get_part_number(self):
|
|
# part number 1 is b_10
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=1')
|
|
status, headers, body = self.call_slo(req)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=1'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get')
|
|
]
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_bc_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '10')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 0-9/25')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '2')
|
|
self.assertEqual(headers['Content-Type'], 'application/octet-stream')
|
|
self.assertEqual(body, b'b' * 10)
|
|
self.assertEqual(headers['X-Object-Meta-Plant'], 'Ficus')
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
# part number 2 is c_15
|
|
self.app.clear_calls()
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=2'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')
|
|
]
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=2')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_bc_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '15')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 10-24/25')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '2')
|
|
self.assertEqual(headers['Content-Type'], 'application/octet-stream')
|
|
self.assertEqual(body, b'c' * 15)
|
|
self.assertEqual(headers['X-Object-Meta-Plant'], 'Ficus')
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
# we now test it with single segment slo
|
|
self.app.clear_calls()
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-segment?part-number=1')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' %
|
|
self.manifest_single_segment_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_single_segment_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '50')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 0-49/50')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Object-Meta-Nature'], 'Regular')
|
|
self.assertEqual(headers['X-Parts-Count'], '1')
|
|
self.assertEqual(headers['Content-Type'], 'application/octet-stream')
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-single-segment?'
|
|
'part-number=1'),
|
|
('GET', '/v1/AUTH_test/gettest/b_50?multipart-manifest=get')
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_get_part_number_sub_slo(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd?part-number=3')
|
|
status, headers, body = self.call_slo(req)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd?part-number=3'),
|
|
('GET', '/v1/AUTH_test/gettest/d_20?multipart-manifest=get')
|
|
]
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '20')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 30-49/50')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '3')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(body, b'd' * 20)
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
self.app.clear_calls()
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd?part-number=2')
|
|
status, headers, body = self.call_slo(req)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd?part-number=2'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')
|
|
]
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_abcd_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '25')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 5-29/50')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '3')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(body, b'b' * 10 + b'c' * 15)
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_get_part_number_large_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcdefghijkl?part-number=10')
|
|
status, headers, body = self.call_slo(req)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcdefghijkl?'
|
|
'part-number=10'),
|
|
('GET', '/v1/AUTH_test/gettest/j_50?multipart-manifest=get')
|
|
]
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' %
|
|
self.manifest_abcdefghijkl_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcdefghijkl_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '50')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 225-274/390')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '12')
|
|
self.assertEqual(headers['Content-Type'], 'application/octet-stream')
|
|
self.assertEqual(body, b'j' * 50)
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_part_number_with_range_segments(self):
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-bc-ranges',
|
|
params={'part-number': 1})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' %
|
|
self.manifest_bc_ranges_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_bc_ranges_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '4')
|
|
self.assertEqual(headers['Content-Range'],
|
|
'bytes 0-3/%s' % self.manifest_bc_ranges_slo_size)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '4')
|
|
self.assertEqual(body, b'b' * 4)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc-ranges?part-number=1'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get')
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
# since the our requested part-number is range-segment we expect Range
|
|
# header on b_10 segment subrequest
|
|
self.assertEqual('bytes=4-7',
|
|
self.app.calls_with_headers[1].headers['Range'])
|
|
|
|
def test_part_number_sub_ranges_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges?part-number=3')
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd-subranges?'
|
|
'part-number=3'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc-ranges'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get')
|
|
]
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' %
|
|
self.manifest_abcd_subranges_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_subranges_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '5')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 6-10/17')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '5')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(body, b'c' * 2 + b'b' * 3)
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_get_part_num_with_repeated_segments(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-aabbccdd?part-number=3',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
|
|
status, headers, body = self.call_slo(req)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-aabbccdd?part-number=3'),
|
|
('GET', '/v1/AUTH_test/gettest/b_10?multipart-manifest=get')
|
|
]
|
|
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' %
|
|
self.manifest_aabbccdd_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_aabbccdd_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '10')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 10-19/100')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '8')
|
|
self.assertEqual(headers['Content-Type'], 'application/octet-stream')
|
|
self.assertEqual(body, b'b' * 10)
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_part_number_zero_invalid(self):
|
|
# part-number query param is 1-indexed, part-number=0 is no joy
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=0')
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body,
|
|
b'Part number must be an integer greater than 0')
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=0')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
self.app.clear_calls()
|
|
self.slo.max_manifest_segments = 3999
|
|
req = Request.blank('/v1/AUTH_test/gettest/manifest-bc',
|
|
params={'part-number': 0})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body,
|
|
b'Part number must be an integer greater than 0')
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_head_part_number_zero_invalid(self):
|
|
# you can HEAD part-number=0 either
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc', method='HEAD',
|
|
params={'part-number': 0})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body, b'') # HEAD response, makes sense
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-bc?part-number=0')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_part_number_zero_invalid_on_subrange(self):
|
|
# either manifest, doesn't matter, part-number=0 is always invalid
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges?part-number=0')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body,
|
|
b'Part number must be an integer greater than 0')
|
|
expected_calls = [
|
|
('GET',
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges?part-number=0')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_negative_part_number_invalid(self):
|
|
# negative numbers are never any good
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=-1')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body,
|
|
b'Part number must be an integer greater than 0')
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=-1')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_head_negative_part_number_invalid_on_subrange(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges', method='HEAD',
|
|
params={'part-number': '-1'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body, b'')
|
|
expected_calls = [
|
|
('HEAD',
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges?part-number=-1')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_head_non_integer_part_number_invalid(self):
|
|
# some kind of string is bad too
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc', method='HEAD',
|
|
params={'part-number': 'foo'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertEqual(body, b'')
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-bc?part-number=foo')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_get_non_integer_part_number_invalid(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc', params={'part-number': 'foo'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body, b'Part number must be an integer greater'
|
|
b' than 0')
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=foo')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_get_out_of_range_part_number(self):
|
|
# you can't go past the actual number of parts either
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=4')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
self.assertEqual(headers['Content-Range'],
|
|
'bytes */%d' % self.manifest_bc_slo_size)
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_bc_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '2')
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertEqual(body, b'The requested part number is not '
|
|
b'satisfiable')
|
|
self.assertEqual(headers['X-Object-Meta-Plant'], 'Ficus')
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=4'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
self.app.clear_calls()
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-single-segment?part-number=2')
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
self.assertEqual(headers['Content-Range'],
|
|
'bytes */%d' % self.manifest_single_segment_slo_size)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_single_segment_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_single_segment_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '1')
|
|
self.assertEqual(body, b'The requested part number is not '
|
|
b'satisfiable')
|
|
self.assertEqual(headers['X-Object-Meta-Nature'], 'Regular')
|
|
expected_app_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-single-segment?'
|
|
'part-number=2'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_head_out_of_range_part_number(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=4')
|
|
req.method = 'HEAD'
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
self.assertEqual(headers['Content-Range'],
|
|
'bytes */%d' % self.manifest_bc_slo_size)
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_bc_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '2')
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertEqual(body, b'')
|
|
self.assertEqual(headers['X-Object-Meta-Plant'], 'Ficus')
|
|
expected_app_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-bc?part-number=4'),
|
|
# segments needed
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=4')
|
|
]
|
|
self.assertEqual(self.app.calls, expected_app_calls)
|
|
|
|
def test_part_number_exceeds_max_manifest_segments_is_ok(self):
|
|
# verify that an existing part can be fetched regardless of the current
|
|
# max_manifest_segments
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?part-number=2')
|
|
self.slo.max_manifest_segments = 1
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '"%s"' % self.manifest_bc_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['Content-Length'], '15')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 10-24/25')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '2')
|
|
self.assertEqual(headers['Content-Type'], 'application/octet-stream')
|
|
self.assertEqual(body, b'c' * 15)
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/gettest/manifest-bc?part-number=2'),
|
|
('GET', '/v1/AUTH_test/gettest/c_15?multipart-manifest=get')
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_part_number_ignored_for_non_slo_object(self):
|
|
# verify that a part-number param is ignored for a non-slo object
|
|
def do_test(query_string):
|
|
self.app.clear_calls()
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/c_15?%s' % query_string)
|
|
self.slo.max_manifest_segments = 1
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'], '%s' % md5hex('c' * 15))
|
|
self.assertEqual(headers['Content-Length'], '15')
|
|
self.assertEqual(body, b'c' * 15)
|
|
self.assertEqual(1, self.app.call_count)
|
|
method, path = self.app.calls[0]
|
|
actual_req = Request.blank(path, method=method)
|
|
self.assertEqual(req.path, actual_req.path)
|
|
self.assertEqual(req.params, actual_req.params)
|
|
|
|
do_test('part-number=-1')
|
|
do_test('part-number=0')
|
|
do_test('part-number=1')
|
|
do_test('part-number=2')
|
|
do_test('part-number=foo')
|
|
do_test('part-number=foo&multipart-manifest=get')
|
|
|
|
def test_part_number_ignored_for_non_slo_object_with_range(self):
|
|
# verify that a part-number param is ignored for a non-slo object
|
|
def do_test(query_string):
|
|
self.app.clear_calls()
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/c_15?%s' % query_string,
|
|
headers={'Range': 'bytes=1-2'})
|
|
self.slo.max_manifest_segments = 1
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'], '%s' % md5hex('c' * 15))
|
|
self.assertEqual(headers['Content-Length'], '2')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 1-2/15')
|
|
self.assertEqual(body, b'c' * 2)
|
|
self.assertEqual(1, self.app.call_count)
|
|
method, path = self.app.calls[0]
|
|
actual_req = Request.blank(path, method=method)
|
|
self.assertEqual(req.path, actual_req.path)
|
|
self.assertEqual(req.params, actual_req.params)
|
|
|
|
do_test('part-number=-1')
|
|
do_test('part-number=0')
|
|
do_test('part-number=1')
|
|
do_test('part-number=2')
|
|
do_test('part-number=foo')
|
|
do_test('part-number=foo&multipart-manifest=get')
|
|
|
|
def test_part_number_ignored_for_manifest_get(self):
|
|
def do_test(query_string):
|
|
self.app.clear_calls()
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-bc?%s' % query_string)
|
|
self.slo.max_manifest_segments = 1
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'], self.manifest_bc_json_md5)
|
|
self.assertEqual(headers['Content-Length'],
|
|
str(self.manifest_bc_json_size))
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Content-Type'],
|
|
'application/json; charset=utf-8')
|
|
self.assertEqual(headers['X-Object-Meta-Plant'], 'Ficus')
|
|
self.assertEqual(1, self.app.call_count)
|
|
method, path = self.app.calls[0]
|
|
actual_req = Request.blank(path, method=method)
|
|
self.assertEqual(req.path, actual_req.path)
|
|
self.assertEqual(req.params, actual_req.params)
|
|
|
|
do_test('part-number=-1&multipart-manifest=get')
|
|
do_test('part-number=0&multipart-manifest=get')
|
|
do_test('part-number=1&multipart-manifest=get')
|
|
do_test('part-number=2&multipart-manifest=get')
|
|
do_test('part-number=foo&multipart-manifest=get')
|
|
|
|
def test_head_out_of_range_part_number_on_subrange(self):
|
|
# you can't go past the actual number of parts either
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges',
|
|
method='HEAD',
|
|
params={'part-number': 6})
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-abcd-subranges?'
|
|
'part-number=6'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd-subranges?'
|
|
'part-number=6')]
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
self.assertEqual(headers['Content-Range'],
|
|
'bytes */%d' % self.manifest_abcd_subranges_slo_size)
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_abcd_subranges_slo_etag)
|
|
self.assertEqual(headers['X-Manifest-Etag'],
|
|
self.manifest_abcd_subranges_json_md5)
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '5')
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertEqual(body, b'')
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_range_with_part_number_is_error(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges?part-number=2',
|
|
headers={'Range': 'bytes=4-12'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '400 Bad Request')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertNotIn('Etag', headers)
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Parts-Count', headers)
|
|
self.assertEqual(body, b'Range requests are not supported with '
|
|
b'part number queries')
|
|
expected_calls = [
|
|
('GET',
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges?part-number=2')
|
|
]
|
|
self.assertEqual(expected_calls, self.app.calls)
|
|
|
|
def test_head_part_number_subrange(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/manifest-abcd-subranges',
|
|
method='HEAD', params={'part-number': 2})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
# Range header can be ignored in a HEAD request
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_abcd_subranges_slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '1')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(headers['X-Parts-Count'], '5')
|
|
self.assertEqual(body, b'') # it's a HEAD request, after all
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/gettest/manifest-abcd-subranges'
|
|
'?part-number=2'),
|
|
('GET', '/v1/AUTH_test/gettest/manifest-abcd-subranges'
|
|
'?part-number=2'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_head_part_number_data_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-data',
|
|
method='HEAD', params={'part-number': 1})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_data_slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '6')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '3')
|
|
self.assertEqual(body, b'') # it's a HEAD request, after all
|
|
expected_calls = [
|
|
('HEAD', '/v1/AUTH_test/c/manifest-data?part-number=1'),
|
|
('GET', '/v1/AUTH_test/c/manifest-data?part-number=1'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
def test_get_part_number_data_manifest(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/c/manifest-data',
|
|
params={'part-number': 3})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Etag'],
|
|
'"%s"' % self.manifest_data_slo_etag)
|
|
self.assertEqual(headers['Content-Length'], '6')
|
|
self.assertEqual(headers['X-Static-Large-Object'], 'true')
|
|
self.assertEqual(headers['X-Parts-Count'], '3')
|
|
self.assertEqual(body, b'ABCDEF')
|
|
expected_calls = [
|
|
('GET', '/v1/AUTH_test/c/manifest-data?part-number=3'),
|
|
]
|
|
self.assertEqual(self.app.calls, expected_calls)
|
|
|
|
|
|
class TestPartNumberLegacyManifest(TestPartNumber):
|
|
|
|
modern_manifest_headers = False
|
|
|
|
|
|
class TestSloBulkDeleter(unittest.TestCase):
|
|
def test_reused_logger(self):
|
|
slo_mware = slo.filter_factory({})('fake app')
|
|
self.assertTrue(slo_mware.logger is slo_mware.bulk_deleter.logger)
|
|
|
|
def test_passes_through_concurrency(self):
|
|
slo_mware = slo.filter_factory({'delete_concurrency': 5})('fake app')
|
|
self.assertEqual(5, slo_mware.bulk_deleter.delete_concurrency)
|
|
|
|
def test_uses_big_max_deletes(self):
|
|
slo_mware = slo.filter_factory(
|
|
{'max_manifest_segments': 123456789})('fake app')
|
|
self.assertGreaterEqual(
|
|
slo_mware.bulk_deleter.max_deletes_per_request,
|
|
123456789)
|
|
|
|
|
|
class TestSwiftInfo(unittest.TestCase):
|
|
def setUp(self):
|
|
registry._swift_info = {}
|
|
registry._swift_admin_info = {}
|
|
|
|
def test_registered_defaults(self):
|
|
mware = slo.filter_factory({})('have to pass in an app')
|
|
swift_info = registry.get_swift_info()
|
|
self.assertTrue('slo' in swift_info)
|
|
self.assertEqual(swift_info['slo'].get('max_manifest_segments'),
|
|
mware.max_manifest_segments)
|
|
self.assertEqual(swift_info['slo'].get('min_segment_size'), 1)
|
|
self.assertEqual(swift_info['slo'].get('max_manifest_size'),
|
|
mware.max_manifest_size)
|
|
self.assertIs(swift_info['slo'].get('allow_async_delete'), True)
|
|
self.assertEqual(1000, mware.max_manifest_segments)
|
|
self.assertEqual(8388608, mware.max_manifest_size)
|
|
self.assertEqual(1048576, mware.rate_limit_under_size)
|
|
self.assertEqual(10, mware.rate_limit_after_segment)
|
|
self.assertEqual(1, mware.rate_limit_segments_per_sec)
|
|
self.assertEqual(10, mware.yield_frequency)
|
|
self.assertEqual(2, mware.concurrency)
|
|
self.assertEqual(2, mware.bulk_deleter.delete_concurrency)
|
|
self.assertIs(True, mware.allow_async_delete)
|
|
|
|
def test_registered_non_defaults(self):
|
|
conf = dict(
|
|
max_manifest_segments=500, max_manifest_size=1048576,
|
|
rate_limit_under_size=2097152, rate_limit_after_segment=20,
|
|
rate_limit_segments_per_sec=2, yield_frequency=5, concurrency=1,
|
|
delete_concurrency=3, allow_async_delete='n')
|
|
mware = slo.filter_factory(conf)('have to pass in an app')
|
|
swift_info = registry.get_swift_info()
|
|
self.assertTrue('slo' in swift_info)
|
|
self.assertEqual(swift_info['slo'].get('max_manifest_segments'), 500)
|
|
self.assertEqual(swift_info['slo'].get('min_segment_size'), 1)
|
|
self.assertEqual(swift_info['slo'].get('max_manifest_size'), 1048576)
|
|
self.assertIs(swift_info['slo'].get('allow_async_delete'), False)
|
|
self.assertEqual(500, mware.max_manifest_segments)
|
|
self.assertEqual(1048576, mware.max_manifest_size)
|
|
self.assertEqual(2097152, mware.rate_limit_under_size)
|
|
self.assertEqual(20, mware.rate_limit_after_segment)
|
|
self.assertEqual(2, mware.rate_limit_segments_per_sec)
|
|
self.assertEqual(5, mware.yield_frequency)
|
|
self.assertEqual(1, mware.concurrency)
|
|
self.assertEqual(3, mware.bulk_deleter.delete_concurrency)
|
|
self.assertIs(False, mware.allow_async_delete)
|
|
|
|
|
|
class TestNonSloPassthrough(SloGETorHEADTestCase):
|
|
|
|
def setUp(self):
|
|
super(TestNonSloPassthrough, self).setUp()
|
|
self._setup_alphabet_objects('a')
|
|
|
|
body = b'big' * 1000
|
|
self.app.register(
|
|
'GET', '/v1/AUTH_test/rangetest/big', swob.HTTPOk, {
|
|
'Content-Length': len(body),
|
|
'Etag': md5hex(body),
|
|
}, body=body)
|
|
|
|
def test_get_nonmanifest_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/gettest/a_5',
|
|
environ={'REQUEST_METHOD': 'GET'})
|
|
status, headers, body = self.call_slo(req)
|
|
|
|
self.assertEqual(status, '200 OK')
|
|
self.assertEqual(headers['Etag'], md5hex('a' * 5))
|
|
self.assertEqual(headers['Content-Length'], '5')
|
|
self.assertNotIn('X-Static-Large-Object', headers)
|
|
self.assertNotIn('X-Manifest-Etag', headers)
|
|
self.assertEqual(body, b'aaaaa')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/gettest/a_5'),
|
|
])
|
|
|
|
def test_non_slo_range_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/rangetest/big',
|
|
headers={'Range': 'bytes=0-4'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertEqual(headers['Content-Length'], '5')
|
|
self.assertEqual(headers['Content-Range'], 'bytes 0-4/3000')
|
|
self.assertEqual(body, b'bigbi')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/rangetest/big'),
|
|
])
|
|
|
|
def test_non_slo_range_unsatisfiable_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/rangetest/big',
|
|
headers={'Range': 'bytes=3001-'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
self.assertEqual(headers['Content-Range'], 'bytes */3000')
|
|
self.assertIn(b'Requested Range Not Satisfiable', body)
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/rangetest/big'),
|
|
])
|
|
|
|
def test_non_slo_multi_range_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/rangetest/big',
|
|
headers={'Range': 'bytes=1-2,3-4'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertNotIn('Content-Range', headers)
|
|
|
|
ct, params = parse_content_type(headers['Content-Type'])
|
|
self.assertEqual(ct, 'multipart/byteranges')
|
|
|
|
params = dict(params)
|
|
boundary = params.get('boundary')
|
|
if six.PY3:
|
|
boundary = boundary.encode('utf-8')
|
|
|
|
self.assertEqual(len(body), int(headers['Content-Length']))
|
|
|
|
got_mime_docs = []
|
|
for mime_doc_fh in iter_multipart_mime_documents(
|
|
BytesIO(body), boundary):
|
|
headers = parse_mime_headers(mime_doc_fh)
|
|
body = mime_doc_fh.read()
|
|
got_mime_docs.append((headers, body))
|
|
self.assertEqual(len(got_mime_docs), 2)
|
|
|
|
first_range_headers, first_range_body = got_mime_docs[0]
|
|
self.assertEqual(first_range_headers['Content-Range'],
|
|
'bytes 1-2/3000')
|
|
self.assertEqual(first_range_body, b'ig')
|
|
|
|
second_range_headers, second_range_body = got_mime_docs[1]
|
|
self.assertEqual(second_range_headers['Content-Range'],
|
|
'bytes 3-4/3000')
|
|
# 012 34 5678
|
|
# big bi gbig
|
|
self.assertEqual(second_range_body, b'bi')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/rangetest/big'),
|
|
])
|
|
|
|
def test_non_slo_multi_range_partially_satisfiable_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/rangetest/big',
|
|
headers={'Range': 'bytes=1-2,3-4,3001-'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
|
|
ct, params = parse_content_type(headers['Content-Type'])
|
|
self.assertEqual(ct, 'multipart/byteranges')
|
|
|
|
params = dict(params)
|
|
boundary = params.get('boundary')
|
|
if six.PY3:
|
|
boundary = boundary.encode('utf-8')
|
|
|
|
self.assertEqual(len(body), int(headers['Content-Length']))
|
|
|
|
got_mime_docs = []
|
|
for mime_doc_fh in iter_multipart_mime_documents(
|
|
BytesIO(body), boundary):
|
|
headers = parse_mime_headers(mime_doc_fh)
|
|
body = mime_doc_fh.read()
|
|
got_mime_docs.append((headers, body))
|
|
self.assertEqual(len(got_mime_docs), 2)
|
|
|
|
first_range_headers, first_range_body = got_mime_docs[0]
|
|
self.assertEqual(first_range_headers['Content-Range'],
|
|
'bytes 1-2/3000')
|
|
self.assertEqual(first_range_body, b'ig')
|
|
|
|
second_range_headers, second_range_body = got_mime_docs[1]
|
|
self.assertEqual(second_range_headers['Content-Range'],
|
|
'bytes 3-4/3000')
|
|
self.assertEqual(second_range_body, b'bi')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/rangetest/big'),
|
|
])
|
|
|
|
def test_non_slo_multi_range_unsatisfiable_passthrough(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/rangetest/big',
|
|
headers={'Range': 'bytes=3001-,3005-3010'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '416 Requested Range Not Satisfiable')
|
|
self.assertEqual(headers['Content-Range'], 'bytes */3000')
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
self.assertIn(b'Requested Range Not Satisfiable', body)
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/rangetest/big'),
|
|
])
|
|
|
|
def test_non_slo_multi_range_starting_beyond_multipart_resp_length(self):
|
|
req = Request.blank(
|
|
'/v1/AUTH_test/rangetest/big',
|
|
headers={'Range': 'bytes=1000-1002,2000-2002'})
|
|
status, headers, body = self.call_slo(req)
|
|
self.assertEqual(status, '206 Partial Content')
|
|
self.assertNotIn('Content-Range', headers)
|
|
self.assertEqual(int(headers['Content-Length']), len(body))
|
|
|
|
ct, params = parse_content_type(headers['Content-Type'])
|
|
self.assertEqual(ct, 'multipart/byteranges')
|
|
|
|
params = dict(params)
|
|
boundary = params.get('boundary')
|
|
if six.PY3:
|
|
boundary = boundary.encode('utf-8')
|
|
|
|
self.assertEqual(len(body), int(headers['Content-Length']))
|
|
|
|
got_mime_docs = []
|
|
for mime_doc_fh in iter_multipart_mime_documents(
|
|
BytesIO(body), boundary):
|
|
headers = parse_mime_headers(mime_doc_fh)
|
|
body = mime_doc_fh.read()
|
|
got_mime_docs.append((headers, body))
|
|
self.assertEqual(len(got_mime_docs), 2)
|
|
|
|
first_range_headers, first_range_body = got_mime_docs[0]
|
|
self.assertEqual(first_range_headers['Content-Range'],
|
|
'bytes 1000-1002/3000')
|
|
self.assertEqual(first_range_body, b'igb')
|
|
|
|
second_range_headers, second_range_body = got_mime_docs[1]
|
|
self.assertEqual(second_range_headers['Content-Range'],
|
|
'bytes 2000-2002/3000')
|
|
self.assertEqual(second_range_body, b'gbi')
|
|
self.assertEqual(self.app.calls, [
|
|
('GET', '/v1/AUTH_test/rangetest/big'),
|
|
])
|
|
|
|
|
|
class TestRespAttrs(unittest.TestCase):
|
|
def test_init_calculates_is_legacy(self):
|
|
attrs = slo.RespAttrs(True, 123456789.12345,
|
|
'manifest-etag', 'slo-etag', 999)
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertIsInstance(attrs.timestamp, Timestamp)
|
|
self.assertEqual('manifest-etag', attrs.json_md5)
|
|
self.assertEqual('slo-etag', attrs.slo_etag)
|
|
self.assertEqual(999, attrs.slo_size)
|
|
# we gave it etag and size!
|
|
self.assertTrue(attrs._has_size_and_etag())
|
|
self.assertFalse(attrs.is_legacy)
|
|
|
|
def test_init_converts_timestamps_from_strings(self):
|
|
attrs = slo.RespAttrs(True, '123456789.12345',
|
|
'manifest-etag', 'slo-etag', 999)
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertIsInstance(attrs.timestamp, Timestamp)
|
|
self.assertEqual('manifest-etag', attrs.json_md5)
|
|
self.assertEqual('slo-etag', attrs.slo_etag)
|
|
self.assertEqual(999, attrs.slo_size)
|
|
# we gave it etag and size!
|
|
self.assertTrue(attrs._has_size_and_etag())
|
|
self.assertFalse(attrs.is_legacy)
|
|
|
|
def test_default_types(self):
|
|
attrs = slo.RespAttrs(None, None, None, None, None)
|
|
# types are correct, values are default/place-holders
|
|
self.assertTrue(attrs.is_slo is False) # not None!
|
|
self.assertEqual(0, attrs.timestamp)
|
|
self.assertIsInstance(attrs.timestamp, Timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
# we didn't provide etag & size
|
|
self.assertFalse(attrs._has_size_and_etag())
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_init_with_no_sysmeta(self):
|
|
now = Timestamp.now()
|
|
attrs = slo.RespAttrs(True, now.normal, None, None, None)
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(now, attrs.timestamp)
|
|
self.assertIsInstance(attrs.timestamp, Timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
# we didn't provide etag & size
|
|
self.assertFalse(attrs._has_size_and_etag())
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_init_with_no_sysmeta_offset(self):
|
|
now = Timestamp.now(offset=123)
|
|
attrs = slo.RespAttrs(True, now.internal, None, None, None)
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(now, attrs.timestamp)
|
|
self.assertIsInstance(attrs.timestamp, Timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
# we didn't provide etag & size
|
|
self.assertFalse(attrs._has_size_and_etag())
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_from_empty_headers(self):
|
|
attrs = slo.RespAttrs.from_headers([])
|
|
self.assertFalse(attrs.is_slo)
|
|
self.assertEqual(0, attrs.timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_from_only_timestamp(self):
|
|
now = Timestamp.now(offset=1)
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', now.internal),
|
|
('X-Irrelevant', 'ignored')])
|
|
self.assertFalse(attrs.is_slo)
|
|
self.assertEqual(now, attrs.timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_legacy_slo_sysmeta(self):
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('Etag', 'manifest-etag'),
|
|
('X-Static-lARGE-Object', 'yes')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('manifest-etag', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_partial_modern_sysmeta(self):
|
|
# missing slo etag
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('Etag', 'manifest-etag'),
|
|
('X-Static-lARGE-Object', 'yes'),
|
|
('x-object-sysmeta-slo-size', '1234')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('manifest-etag', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(1234, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
# missing slo size
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('Etag', 'manifest-etag'),
|
|
('X-Static-lARGE-Object', 'yes'),
|
|
('x-object-sysmeta-slo-etag', 'slo-etag')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('manifest-etag', attrs.json_md5)
|
|
self.assertEqual('slo-etag', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
# missing manifest etag
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('X-Static-lARGE-Object', 'yes'),
|
|
('x-object-sysmeta-slo-size', '1234'),
|
|
('x-object-sysmeta-slo-etag', 'slo-etag')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('slo-etag', attrs.slo_etag)
|
|
self.assertEqual(1234, attrs.slo_size)
|
|
# missing Etag might be some kind of bug, but it has all sysmeta
|
|
self.assertFalse(attrs.is_legacy)
|
|
|
|
def test_invalid_sysmeta(self):
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('X-Static-lARGE-Object', 'yes'),
|
|
('x-object-sysmeta-slo-size', 'huge!')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('X-Static-lARGE-Object', 'yes'),
|
|
('e-TAG', 'wrong!'),
|
|
('x-object-sysmeta-slo-size', '')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_from_valid_sysmeta(self):
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('Etag', 'manifest-etag'),
|
|
('X-Static-lARGE-Object', 'yes'),
|
|
('x-object-sysmeta-slo-etag', 'slo-tag'),
|
|
('x-object-sysmeta-slo-size', '1234')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('manifest-etag', attrs.json_md5)
|
|
self.assertEqual('slo-tag', attrs.slo_etag)
|
|
self.assertEqual(1234, attrs.slo_size)
|
|
self.assertFalse(attrs.is_legacy)
|
|
|
|
def test_from_regular_object(self):
|
|
now = Timestamp.now()
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', now.normal),
|
|
('Etag', 'object-etag')])
|
|
self.assertFalse(attrs.is_slo)
|
|
self.assertEqual(now, attrs.timestamp)
|
|
# N.B. we only set manifest_etag on slo objects
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_non_slo_with_sysmeta(self):
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('X-Static-lARGE-Object', 'false')])
|
|
self.assertFalse(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('Etag', 'segment-etag'),
|
|
('x-object-sysmeta-slo-etag', 'tag'),
|
|
('x-object-sysmeta-slo-size', '1234')])
|
|
# this is NOT an SLO
|
|
self.assertFalse(attrs.is_slo)
|
|
self.assertEqual('', attrs.json_md5)
|
|
# ... but we set these based on the sysmeta values
|
|
self.assertEqual('tag', attrs.slo_etag)
|
|
self.assertEqual(1234, attrs.slo_size)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
# I hope someday a non-slo with slo sysmeta *will* be just a legacy,
|
|
# see lp bug #2035158
|
|
self.assertFalse(attrs.is_legacy)
|
|
|
|
def test_from_zero_byte_sysmeta(self):
|
|
attrs = slo.RespAttrs.from_headers([
|
|
('X-Backend-Timestamp', '1709069771.34178'),
|
|
('X-Object-Sysmeta-Container-Update-Override-Etag',
|
|
'a1eadf0ca181e87fcbdba2074ce0fd90; '
|
|
's3_etag=59adb24ef3cdbe0297f05b395827453f-1; '
|
|
'slo_etag=74be16979710d4c4e7c6647856088456'),
|
|
('X-Object-Sysmeta-S3Api-Etag',
|
|
'59adb24ef3cdbe0297f05b395827453f-1'),
|
|
('X-Object-Sysmeta-S3Api-Upload-Id',
|
|
'NDZlMDBhN2MtNzVmZS00ZTljLTkzN2EtODcwNGQ5OTg4NmQ2'),
|
|
('X-Object-Sysmeta-Slo-Etag', '74be16979710d4c4e7c6647856088456'),
|
|
('X-Object-Sysmeta-Slo-Size', '0'),
|
|
('ETag', 'a1eadf0ca181e87fcbdba2074ce0fd90'),
|
|
('X-Static-Large-Object', 'True'),
|
|
])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(1709069771.34178, attrs.timestamp)
|
|
self.assertEqual('a1eadf0ca181e87fcbdba2074ce0fd90', attrs.json_md5)
|
|
self.assertEqual('74be16979710d4c4e7c6647856088456', attrs.slo_etag)
|
|
self.assertEqual(0, attrs.slo_size)
|
|
self.assertFalse(attrs.is_legacy)
|
|
|
|
def _legacy_from_headers(self):
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('Etag', 'manifest-etag'),
|
|
('X-Static-lARGE-Object', 'yes')])
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('manifest-etag', attrs.json_md5)
|
|
self.assertEqual('', attrs.slo_etag)
|
|
self.assertEqual(-1, attrs.slo_size)
|
|
self.assertTrue(attrs.is_legacy)
|
|
return attrs
|
|
|
|
def test_update_from_segments(self):
|
|
attrs = self._legacy_from_headers()
|
|
segments = [
|
|
{'hash': 'abc', 'bytes': 2},
|
|
{'hash': 'def', 'bytes': 3},
|
|
]
|
|
slo._annotate_segments(segments)
|
|
attrs.update_from_segments(segments)
|
|
|
|
exp_etag = md5('abcdef'.encode('ascii'), usedforsecurity=False)
|
|
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual(exp_etag.hexdigest(), attrs.slo_etag)
|
|
self.assertEqual(5, attrs.slo_size)
|
|
# N.B. it's still a legacy manifest
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_update_from_segments_with_raw_data(self):
|
|
attrs = self._legacy_from_headers()
|
|
raw_data = b'something'
|
|
segments = [
|
|
{'hash': 'abc', 'bytes': 2},
|
|
{'data': base64.b64encode(raw_data)},
|
|
]
|
|
slo._annotate_segments(segments)
|
|
attrs.update_from_segments(segments)
|
|
|
|
raw_data_checksum = md5(raw_data).hexdigest()
|
|
exp_etag = md5(('abc' + raw_data_checksum).encode('ascii'),
|
|
usedforsecurity=False)
|
|
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual(exp_etag.hexdigest(), attrs.slo_etag)
|
|
self.assertEqual(11, attrs.slo_size)
|
|
# N.B. it's still a legacy manifest
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_update_from_segments_with_range(self):
|
|
attrs = self._legacy_from_headers()
|
|
segments = [
|
|
{'hash': 'abc', 'bytes': 2},
|
|
{'hash': 'def', 'range': '1-2'},
|
|
]
|
|
slo._annotate_segments(segments)
|
|
attrs.update_from_segments(segments)
|
|
|
|
exp_etag = md5('abcdef:1-2;'.encode('ascii'), usedforsecurity=False)
|
|
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual(exp_etag.hexdigest(), attrs.slo_etag)
|
|
self.assertEqual(4, attrs.slo_size)
|
|
# N.B. it's still a legacy manifest
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_update_from_segments_with_sub_slo(self):
|
|
attrs = self._legacy_from_headers()
|
|
content_type = 'application/octet-stream'
|
|
content_type += ";swift_bytes=%d" % 5
|
|
segments = [
|
|
{'hash': 'abc', 'bytes': 2},
|
|
{'hash': '123', 'sub_slo': True, 'content_type': content_type},
|
|
]
|
|
slo._annotate_segments(segments)
|
|
attrs.update_from_segments(segments)
|
|
|
|
exp_etag = md5('abc123'.encode('ascii'), usedforsecurity=False)
|
|
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual(exp_etag.hexdigest(), attrs.slo_etag)
|
|
self.assertEqual(7, attrs.slo_size)
|
|
# N.B. it's still a legacy manifest
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_update_from_segments_with_sub_slo_range(self):
|
|
attrs = self._legacy_from_headers()
|
|
content_type = 'application/octet-stream'
|
|
content_type += ";swift_bytes=%d" % 5
|
|
segments = [
|
|
{'hash': 'abc', 'bytes': 2},
|
|
{'hash': '123', 'sub_slo': True, 'content_type': content_type,
|
|
'range': '2-4'},
|
|
]
|
|
slo._annotate_segments(segments)
|
|
attrs.update_from_segments(segments)
|
|
|
|
exp_etag = md5('abc123:2-4;'.encode('ascii'), usedforsecurity=False)
|
|
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual(exp_etag.hexdigest(), attrs.slo_etag)
|
|
self.assertEqual(5, attrs.slo_size)
|
|
# N.B. it's still a legacy manifest
|
|
self.assertTrue(attrs.is_legacy)
|
|
|
|
def test_update_from_segments_not_legacy(self):
|
|
attrs = slo.RespAttrs.from_headers(
|
|
[('X-Backend-Timestamp', '123456789.12345'),
|
|
('X-Static-lARGE-Object', 'yes'),
|
|
('x-object-sysmeta-slo-etag', 'tag'),
|
|
('x-object-sysmeta-slo-size', '1234')])
|
|
|
|
segments = 'not even json; does not matter'
|
|
attrs.update_from_segments(segments)
|
|
|
|
self.assertTrue(attrs.is_slo)
|
|
self.assertEqual(123456789.12345, attrs.timestamp)
|
|
self.assertEqual('tag', attrs.slo_etag)
|
|
self.assertEqual(1234, attrs.slo_size)
|
|
# N.B. it's still a legacy manifest
|
|
self.assertFalse(attrs.is_legacy)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|