示例#1
0
    def test_HEAD_x_newest_with_some_missing(self):
        req = swob.Request.blank('/v1/a/c/o', method='HEAD',
                                 headers={'X-Newest': 'true'})
        ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
        request_count = self.app.request_node_count(self.obj_ring.replicas)
        backend_response_headers = [{
            'x-timestamp': next(ts).normal,
        } for i in range(request_count)]
        responses = [404] * (request_count - 1)
        responses.append(200)
        request_log = []

        def capture_requests(ip, port, device, part, method, path,
                             headers=None, **kwargs):
            req = {
                'ip': ip,
                'port': port,
                'device': device,
                'part': part,
                'method': method,
                'path': path,
                'headers': headers,
            }
            request_log.append(req)
        with set_http_connect(*responses,
                              headers=backend_response_headers,
                              give_connect=capture_requests):
            resp = req.get_response(self.app)
        self.assertEqual(resp.status_int, 200)
        for req in request_log:
            self.assertEqual(req['method'], 'HEAD')
            self.assertEqual(req['path'], '/a/c/o')
 def test_make_delete_jobs_native_utf8(self):
     ts = '1558463777.42739'
     uacct = acct = u'acct-\U0001f334'
     ucont = cont = u'cont-\N{SNOWMAN}'
     uobj1 = obj1 = u'obj-\N{GREEK CAPITAL LETTER ALPHA}'
     uobj2 = obj2 = u'/obj-\N{GREEK CAPITAL LETTER OMEGA}'
     if six.PY2:
         acct = acct.encode('utf8')
         cont = cont.encode('utf8')
         obj1 = obj1.encode('utf8')
         obj2 = obj2.encode('utf8')
     self.assertEqual(
         container_deleter.make_delete_jobs(acct, cont, [obj1, obj2],
                                            utils.Timestamp(ts)),
         [{
             'name': u'%s-%s/%s/%s' % (ts, uacct, ucont, uobj1),
             'deleted': 0,
             'created_at': ts,
             'etag': utils.MD5_OF_EMPTY_STRING,
             'size': 0,
             'storage_policy_index': 0,
             'content_type': 'application/async-deleted'
         }, {
             'name': u'%s-%s/%s/%s' % (ts, uacct, ucont, uobj2),
             'deleted': 0,
             'created_at': ts,
             'etag': utils.MD5_OF_EMPTY_STRING,
             'size': 0,
             'storage_policy_index': 0,
             'content_type': 'application/async-deleted'
         }])
 def test_make_delete_jobs_unicode_utf8(self):
     ts = '1558463777.42739'
     acct = u'acct-\U0001f334'
     cont = u'cont-\N{SNOWMAN}'
     obj1 = u'obj-\N{GREEK CAPITAL LETTER ALPHA}'
     obj2 = u'obj-\N{GREEK CAPITAL LETTER OMEGA}'
     self.assertEqual(
         container_deleter.make_delete_jobs(acct, cont, [obj1, obj2],
                                            utils.Timestamp(ts)),
         [{
             'name': u'%s-%s/%s/%s' % (ts.split('.')[0], acct, cont, obj1),
             'deleted': 0,
             'created_at': ts,
             'etag': utils.MD5_OF_EMPTY_STRING,
             'size': 0,
             'storage_policy_index': 0,
             'content_type': 'application/async-deleted'
         }, {
             'name': u'%s-%s/%s/%s' % (ts.split('.')[0], acct, cont, obj2),
             'deleted': 0,
             'created_at': ts,
             'etag': utils.MD5_OF_EMPTY_STRING,
             'size': 0,
             'storage_policy_index': 0,
             'content_type': 'application/async-deleted'
         }])
 def test_mark_for_deletion_one_update_no_yield(self):
     ts = '1558463777.42739'
     with FakeInternalClient([
             swob.Response(
                 json.dumps([
                     {
                         'name': '/obj1'
                     },
                     {
                         'name': 'obj2'
                     },
                     {
                         'name': 'obj3'
                     },
                 ])),
             swob.Response(json.dumps([])),
             swob.Response(status=202),
     ]) as swift:
         self.assertEqual(
             container_deleter.mark_for_deletion(
                 swift,
                 'account',
                 'container',
                 '',
                 '',
                 '',
                 timestamp=utils.Timestamp(ts),
                 yield_time=None,
             ), 3)
         self.assertEqual(swift.calls, [
             ('GET', '/v1/account/container',
              'format=json&marker=&end_marker=&prefix=', {}, None),
             ('GET', '/v1/account/container',
              'format=json&marker=obj3&end_marker=&prefix=', {}, None),
             ('UPDATE', '/v1/.expiring_objects/' + ts.split('.')[0], '', {
                 'X-Backend-Allow-Private-Methods': 'True',
                 'X-Backend-Storage-Policy-Index': '0',
                 'X-Timestamp': ts
             }, mock.ANY),
         ])
         self.assertEqual(
             json.loads(swift.calls[-1].body),
             container_deleter.make_delete_jobs('account', 'container',
                                                ['/obj1', 'obj2', 'obj3'],
                                                utils.Timestamp(ts)))
示例#5
0
 def test_send_delete(self):
     self.sender.connection = FakeConnection()
     self.sender.send_delete('/a/c/o',
                             utils.Timestamp('1381679759.90941'))
     self.assertEqual(
         ''.join(self.sender.connection.sent),
         '30\r\n'
         'DELETE /a/c/o\r\n'
         'X-Timestamp: 1381679759.90941\r\n'
         '\r\n\r\n')
示例#6
0
 def test_valid_timestamp(self):
     self.assertRaises(HTTPException, constraints.valid_timestamp,
                       Request.blank('/'))
     self.assertRaises(HTTPException, constraints.valid_timestamp,
                       Request.blank('/', headers={'X-Timestamp': 'asdf'}))
     timestamp = utils.Timestamp(time.time())
     req = Request.blank('/', headers={'X-Timestamp': timestamp.internal})
     self.assertEqual(timestamp, constraints.valid_timestamp(req))
     req = Request.blank('/', headers={'X-Timestamp': timestamp.normal})
     self.assertEqual(timestamp, constraints.valid_timestamp(req))
示例#7
0
 def test_send_delete_timeout(self):
     self.sender.connection = FakeConnection()
     self.sender.connection.send = lambda d: eventlet.sleep(1)
     self.sender.daemon.node_timeout = 0.01
     exc = None
     try:
         self.sender.send_delete('/a/c/o',
                                 utils.Timestamp('1381679759.90941'))
     except exceptions.MessageTimeout as err:
         exc = err
     self.assertEqual(str(exc), '0.01 seconds: send_delete')
示例#8
0
 def test_container_sync_delete(self):
     ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
     test_indexes = [None] + [int(p) for p in POLICIES]
     for policy_index in test_indexes:
         req = swob.Request.blank(
             '/v1/a/c/o', method='DELETE', headers={
                 'X-Timestamp': ts.next().internal})
         codes = [409] * self.obj_ring.replicas
         ts_iter = itertools.repeat(ts.next().internal)
         with set_http_connect(*codes, timestamps=ts_iter):
             resp = req.get_response(self.app)
         self.assertEqual(resp.status_int, 409)
示例#9
0
 def test_put_x_timestamp_conflict(self):
     ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
     req = swob.Request.blank(
         '/v1/a/c/o', method='PUT', headers={
             'Content-Length': 0,
             'X-Timestamp': ts.next().internal})
     head_resp = [404] * self.obj_ring.replicas + \
         [404] * self.obj_ring.max_more_nodes
     put_resp = [409] + [201] * self.obj_ring.replicas
     codes = head_resp + put_resp
     with set_http_connect(*codes):
         resp = req.get_response(self.app)
     self.assertEqual(resp.status_int, 201)
示例#10
0
 def test_put_x_timestamp_conflict(self):
     ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
     req = swob.Request.blank('/v1/a/c/o',
                              method='PUT',
                              headers={
                                  'Content-Length': 0,
                                  'X-Timestamp': ts.next().internal
                              })
     ts_iter = iter([ts.next().internal, None, None])
     codes = [409] + [201] * (self.obj_ring.replicas - 1)
     with set_http_connect(*codes, timestamps=ts_iter):
         resp = req.get_response(self.app)
     self.assertEqual(resp.status_int, 202)
示例#11
0
def make_ec_object_stub(test_body, policy, timestamp):
    segment_size = policy.ec_segment_size
    test_body = test_body or ('test' * segment_size)[:-random.randint(1, 1000)]
    timestamp = timestamp or utils.Timestamp(time.time())
    etag = md5(test_body).hexdigest()
    ec_archive_bodies = encode_frag_archive_bodies(policy, test_body)

    return {
        'body': test_body,
        'etag': etag,
        'frags': ec_archive_bodies,
        'timestamp': timestamp
    }
示例#12
0
 def test_container_sync_put_x_timestamp_not_found(self):
     test_indexes = [None] + [int(p) for p in POLICIES]
     for policy_index in test_indexes:
         self.container_info['storage_policy'] = policy_index
         put_timestamp = utils.Timestamp(time.time()).normal
         req = swob.Request.blank('/v1/a/c/o',
                                  method='PUT',
                                  headers={
                                      'Content-Length': 0,
                                      'X-Timestamp': put_timestamp
                                  })
         codes = [201] * self.obj_ring.replicas
         with set_http_connect(*codes):
             resp = req.get_response(self.app)
         self.assertEqual(resp.status_int, 201)
示例#13
0
 def test_container_sync_put_x_timestamp_newer(self):
     ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
     test_indexes = [None] + [int(p) for p in POLICIES]
     for policy_index in test_indexes:
         orig_timestamp = ts.next().internal
         req = swob.Request.blank('/v1/a/c/o',
                                  method='PUT',
                                  headers={
                                      'Content-Length': 0,
                                      'X-Timestamp': ts.next().internal
                                  })
         ts_iter = itertools.repeat(orig_timestamp)
         codes = [201] * self.obj_ring.replicas
         with set_http_connect(*codes, timestamps=ts_iter):
             resp = req.get_response(self.app)
         self.assertEqual(resp.status_int, 201)
示例#14
0
 def test_HEAD_x_newest_different_timestamps(self):
     req = swob.Request.blank('/v1/a/c/o',
                              method='HEAD',
                              headers={'X-Newest': 'true'})
     ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
     timestamps = [next(ts) for i in range(3)]
     newest_timestamp = timestamps[-1]
     random.shuffle(timestamps)
     backend_response_headers = [{
         'X-Backend-Timestamp': t.internal,
         'X-Timestamp': t.normal
     } for t in timestamps]
     with set_http_connect(200, 200, 200, headers=backend_response_headers):
         resp = req.get_response(self.app)
     self.assertEqual(resp.status_int, 200)
     self.assertEqual(resp.headers['x-timestamp'], newest_timestamp.normal)
示例#15
0
 def test_container_sync_put_x_timestamp_older(self):
     ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
     test_indexes = [None] + [int(p) for p in POLICIES]
     for policy_index in test_indexes:
         self.container_info['storage_policy'] = policy_index
         req = swob.Request.blank(
             '/v1/a/c/o', method='PUT', headers={
                 'Content-Length': 0,
                 'X-Timestamp': ts.next().internal})
         ts_iter = itertools.repeat(ts.next().internal)
         head_resp = [200] * self.obj_ring.replicas + \
             [404] * self.obj_ring.max_more_nodes
         codes = head_resp
         with set_http_connect(*codes, timestamps=ts_iter):
             resp = req.get_response(self.app)
         self.assertEqual(resp.status_int, 202)
示例#16
0
    def test_container_sync_put_x_timestamp_race(self):
        ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
        test_indexes = [None] + [int(p) for p in POLICIES]
        for policy_index in test_indexes:
            put_timestamp = ts.next().internal
            req = swob.Request.blank('/v1/a/c/o',
                                     method='PUT',
                                     headers={
                                         'Content-Length': 0,
                                         'X-Timestamp': put_timestamp
                                     })

            # object nodes they respond 409 because another in-flight request
            # finished and now the on disk timestamp is equal to the request.
            put_ts = [put_timestamp] * self.obj_ring.replicas
            codes = [409] * self.obj_ring.replicas

            ts_iter = iter(put_ts)
            with set_http_connect(*codes, timestamps=ts_iter):
                resp = req.get_response(self.app)
            self.assertEqual(resp.status_int, 202)
示例#17
0
 def test_make_delete_jobs(self):
     ts = '1558463777.42739'
     self.assertEqual(
         container_deleter.make_delete_jobs('acct', 'cont',
                                            ['obj1', 'obj2'],
                                            utils.Timestamp(ts)),
         [{
             'name': ts + '-acct/cont/obj1',
             'deleted': 0,
             'created_at': ts,
             'etag': utils.MD5_OF_EMPTY_STRING,
             'size': 0,
             'storage_policy_index': 0,
             'content_type': 'application/async-deleted'
         }, {
             'name': ts + '-acct/cont/obj2',
             'deleted': 0,
             'created_at': ts,
             'etag': utils.MD5_OF_EMPTY_STRING,
             'size': 0,
             'storage_policy_index': 0,
             'content_type': 'application/async-deleted'
         }])
示例#18
0
    def test_container_sync_put_x_timestamp_unsynced_race(self):
        ts = (utils.Timestamp(t) for t in itertools.count(int(time.time())))
        test_indexes = [None] + [int(p) for p in POLICIES]
        for policy_index in test_indexes:
            put_timestamp = ts.next().internal
            req = swob.Request.blank('/v1/a/c/o',
                                     method='PUT',
                                     headers={
                                         'Content-Length': 0,
                                         'X-Timestamp': put_timestamp
                                     })

            # only one in-flight request finished
            put_ts = [None] * (self.obj_ring.replicas - 1)
            put_resp = [201] * (self.obj_ring.replicas - 1)
            put_ts += [put_timestamp]
            put_resp += [409]

            ts_iter = iter(put_ts)
            codes = put_resp
            with set_http_connect(*codes, timestamps=ts_iter):
                resp = req.get_response(self.app)
            self.assertEqual(resp.status_int, 202)
示例#19
0
    def test_check_delete_headers(self):
        # x-delete-at value should be relative to the request timestamp rather
        # than time.time() so separate the two to ensure the checks are robust
        ts = utils.Timestamp(time.time() + 100)

        # X-Delete-After
        headers = {'X-Delete-After': '600', 'X-Timestamp': ts.internal}
        req = constraints.check_delete_headers(
            Request.blank('/', headers=headers))
        self.assertIsInstance(req, Request)
        self.assertIn('x-delete-at', req.headers)
        self.assertNotIn('x-delete-after', req.headers)
        expected_delete_at = str(int(ts) + 600)
        self.assertEqual(req.headers.get('X-Delete-At'), expected_delete_at)

        headers = {'X-Delete-After': 'abc', 'X-Timestamp': ts.internal}

        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'Non-integer X-Delete-After', cm.exception.body)

        headers = {'X-Delete-After': '60.1', 'X-Timestamp': ts.internal}
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'Non-integer X-Delete-After', cm.exception.body)

        headers = {'X-Delete-After': '-1', 'X-Timestamp': ts.internal}
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'X-Delete-After in past', cm.exception.body)

        headers = {'X-Delete-After': '0', 'X-Timestamp': ts.internal}
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'X-Delete-After in past', cm.exception.body)

        # x-delete-after = 0 disallowed when it results in x-delete-at equal to
        # the timestamp
        headers = {
            'X-Delete-After': '0',
            'X-Timestamp': utils.Timestamp(int(ts)).internal
        }
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'X-Delete-After in past', cm.exception.body)

        # X-Delete-At
        delete_at = str(int(ts) + 100)
        headers = {'X-Delete-At': delete_at, 'X-Timestamp': ts.internal}
        req = constraints.check_delete_headers(
            Request.blank('/', headers=headers))
        self.assertIsInstance(req, Request)
        self.assertIn('x-delete-at', req.headers)
        self.assertEqual(req.headers.get('X-Delete-At'), delete_at)

        headers = {'X-Delete-At': 'abc', 'X-Timestamp': ts.internal}
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'Non-integer X-Delete-At', cm.exception.body)

        delete_at = str(int(ts) + 100) + '.1'
        headers = {'X-Delete-At': delete_at, 'X-Timestamp': ts.internal}
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'Non-integer X-Delete-At', cm.exception.body)

        delete_at = str(int(ts) - 1)
        headers = {'X-Delete-At': delete_at, 'X-Timestamp': ts.internal}
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'X-Delete-At in past', cm.exception.body)

        # x-delete-at disallowed when exactly equal to timestamp
        delete_at = str(int(ts))
        headers = {
            'X-Delete-At': delete_at,
            'X-Timestamp': utils.Timestamp(int(ts)).internal
        }
        with self.assertRaises(HTTPException) as cm:
            constraints.check_delete_headers(
                Request.blank('/', headers=headers))
        self.assertEqual(cm.exception.status_int, HTTP_BAD_REQUEST)
        self.assertIn(b'X-Delete-At in past', cm.exception.body)
示例#20
0
    def test_print_obj_metadata(self):
        self.assertRaisesMessage(ValueError, 'Metadata is None',
                                 print_obj_metadata, [])

        def get_metadata(items):
            md = dict(name='/AUTH_admin/c/dummy')
            md['Content-Type'] = 'application/octet-stream'
            md['X-Timestamp'] = 106.3
            md.update(items)
            return md

        metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'})
        out = StringIO()
        with mock.patch('sys.stdout', out):
            print_obj_metadata(metadata)
        exp_out = '''Path: /AUTH_admin/c/dummy
  Account: AUTH_admin
  Container: c
  Object: dummy
  Object hash: 128fdf98bddd1b1e8695f4340e67a67a
Content-Type: application/octet-stream
Timestamp: 1970-01-01T00:01:46.300000 (%s)
System Metadata:
  No metadata found
User Metadata:
  X-Object-Meta-Mtime: 107.3
Other Metadata:
  No metadata found''' % (utils.Timestamp(106.3).internal)

        self.assertEqual(out.getvalue().strip(), exp_out)

        metadata = get_metadata({
            'X-Object-Sysmeta-Mtime': '107.3',
            'X-Object-Sysmeta-Name': 'Obj name',
        })
        out = StringIO()
        with mock.patch('sys.stdout', out):
            print_obj_metadata(metadata)
        exp_out = '''Path: /AUTH_admin/c/dummy
  Account: AUTH_admin
  Container: c
  Object: dummy
  Object hash: 128fdf98bddd1b1e8695f4340e67a67a
Content-Type: application/octet-stream
Timestamp: 1970-01-01T00:01:46.300000 (%s)
System Metadata:
  X-Object-Sysmeta-Mtime: 107.3
  X-Object-Sysmeta-Name: Obj name
User Metadata:
  No metadata found
Other Metadata:
  No metadata found''' % (utils.Timestamp(106.3).internal)

        self.assertEqual(out.getvalue().strip(), exp_out)

        metadata = get_metadata({
            'X-Object-Meta-Mtime': '107.3',
            'X-Object-Sysmeta-Mtime': '107.3',
            'X-Object-Mtime': '107.3',
        })
        out = StringIO()
        with mock.patch('sys.stdout', out):
            print_obj_metadata(metadata)
        exp_out = '''Path: /AUTH_admin/c/dummy
  Account: AUTH_admin
  Container: c
  Object: dummy
  Object hash: 128fdf98bddd1b1e8695f4340e67a67a
Content-Type: application/octet-stream
Timestamp: 1970-01-01T00:01:46.300000 (%s)
System Metadata:
  X-Object-Sysmeta-Mtime: 107.3
User Metadata:
  X-Object-Meta-Mtime: 107.3
Other Metadata:
  X-Object-Mtime: 107.3''' % (utils.Timestamp(106.3).internal)

        self.assertEqual(out.getvalue().strip(), exp_out)

        metadata = get_metadata({})
        out = StringIO()
        with mock.patch('sys.stdout', out):
            print_obj_metadata(metadata)
        exp_out = '''Path: /AUTH_admin/c/dummy
  Account: AUTH_admin
  Container: c
  Object: dummy
  Object hash: 128fdf98bddd1b1e8695f4340e67a67a
Content-Type: application/octet-stream
Timestamp: 1970-01-01T00:01:46.300000 (%s)
System Metadata:
  No metadata found
User Metadata:
  No metadata found
Other Metadata:
  No metadata found''' % (utils.Timestamp(106.3).internal)

        self.assertEqual(out.getvalue().strip(), exp_out)

        metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'})
        metadata['name'] = '/a-s'
        self.assertRaisesMessage(ValueError, 'Path is invalid',
                                 print_obj_metadata, metadata)

        metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'})
        del metadata['name']
        out = StringIO()
        with mock.patch('sys.stdout', out):
            print_obj_metadata(metadata)
        exp_out = '''Path: Not found in metadata
Content-Type: application/octet-stream
Timestamp: 1970-01-01T00:01:46.300000 (%s)
System Metadata:
  No metadata found
User Metadata:
  X-Object-Meta-Mtime: 107.3
Other Metadata:
  No metadata found''' % (utils.Timestamp(106.3).internal)

        self.assertEqual(out.getvalue().strip(), exp_out)

        metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'})
        del metadata['Content-Type']
        out = StringIO()
        with mock.patch('sys.stdout', out):
            print_obj_metadata(metadata)
        exp_out = '''Path: /AUTH_admin/c/dummy
  Account: AUTH_admin
  Container: c
  Object: dummy
  Object hash: 128fdf98bddd1b1e8695f4340e67a67a
Content-Type: Not found in metadata
Timestamp: 1970-01-01T00:01:46.300000 (%s)
System Metadata:
  No metadata found
User Metadata:
  X-Object-Meta-Mtime: 107.3
Other Metadata:
  No metadata found''' % (utils.Timestamp(106.3).internal)

        self.assertEqual(out.getvalue().strip(), exp_out)

        metadata = get_metadata({'X-Object-Meta-Mtime': '107.3'})
        del metadata['X-Timestamp']
        out = StringIO()
        with mock.patch('sys.stdout', out):
            print_obj_metadata(metadata)
        exp_out = '''Path: /AUTH_admin/c/dummy
  Account: AUTH_admin
  Container: c
  Object: dummy
  Object hash: 128fdf98bddd1b1e8695f4340e67a67a
Content-Type: application/octet-stream
Timestamp: Not found in metadata
System Metadata:
  No metadata found
User Metadata:
  X-Object-Meta-Mtime: 107.3
Other Metadata:
  No metadata found'''

        self.assertEqual(out.getvalue().strip(), exp_out)