From c5152ed4d3a37c5e0017be9f0445bf0868561b96 Mon Sep 17 00:00:00 2001 From: Tim Burke Date: Thu, 11 Jan 2018 16:24:24 -0800 Subject: [PATCH] Add some functional CORS tests If you've got selenium installed (and working), the whole thing can be automated pretty well; run main.py, wait while some windows pop up (or use xvfb-run to run things on a virtual display), then check out what tests were run on which browsers and whether any of them failed. Exit code is the number of failed tests. Includes tests against: - Account - Containers, with various ACLs/CORS settings - Objects - /info - SLOs - DLOs - Symlinks Include a gate job that runs the tests in firefox. Areas for future work: - Install chromium and chromedriver in the gate; tests should automatically pick up on the fact that it's available - Capture the web browser's console logs, too, so we can get more info when things go wrong Change-Id: Ic1d3a062419f1133c6e2f00a598867d567358c9f --- .zuul.yaml | 27 +- test/cors/README.rst | 97 +++++++ test/cors/harness.js | 251 ++++++++++++++++ test/cors/index.html | 42 +++ test/cors/main.py | 317 +++++++++++++++++++++ test/cors/test-account.js | 16 ++ test/cors/test-container.js | 148 ++++++++++ test/cors/test-info.js | 60 ++++ test/cors/test-large-objects.js | 93 ++++++ test/cors/test-object.js | 169 +++++++++++ test/cors/test-symlink.js | 139 +++++++++ tools/playbooks/cors/install_selenium.yaml | 30 ++ tools/playbooks/cors/post.yaml | 25 ++ tools/playbooks/cors/run.yaml | 15 + 14 files changed, 1425 insertions(+), 4 deletions(-) create mode 100644 test/cors/README.rst create mode 100644 test/cors/harness.js create mode 100644 test/cors/index.html create mode 100755 test/cors/main.py create mode 100644 test/cors/test-account.js create mode 100644 test/cors/test-container.js create mode 100644 test/cors/test-info.js create mode 100644 test/cors/test-large-objects.js create mode 100644 test/cors/test-object.js create mode 100644 test/cors/test-symlink.js create mode 100644 tools/playbooks/cors/install_selenium.yaml create mode 100644 tools/playbooks/cors/post.yaml create mode 100644 tools/playbooks/cors/run.yaml diff --git a/.zuul.yaml b/.zuul.yaml index 6b26bfae7c..ff2cd0278b 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -309,6 +309,17 @@ vars: bindep_profile: test py36 +- job: + name: swift-func-cors + parent: swift-probetests-centos-7 + description: | + Setup a SAIO dev environment and run Swift's CORS functional tests + timeout: 1200 + pre-run: + - tools/playbooks/cors/install_selenium.yaml + run: tools/playbooks/cors/run.yaml + post-run: tools/playbooks/cors/post.yaml + - nodeset: name: swift-five-nodes nodes: @@ -515,7 +526,7 @@ - swift-tox-py27: irrelevant-files: &unittest-irrelevant-files - ^(api-ref|doc|releasenotes)/.*$ - - ^test/(functional|probe)/.*$ + - ^test/(cors|functional|probe)/.*$ - swift-tox-py36: irrelevant-files: *unittest-irrelevant-files - swift-tox-py37: @@ -529,7 +540,7 @@ - swift-tox-func-py27: irrelevant-files: &functest-irrelevant-files - ^(api-ref|doc|releasenotes)/.*$ - - ^test/probe/.*$ + - ^test/(cors|probe)/.*$ - ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$ - swift-tox-func-encryption-py27: irrelevant-files: *functest-irrelevant-files @@ -545,20 +556,27 @@ irrelevant-files: *functest-irrelevant-files # Other tests + - swift-func-cors: + irrelevant-files: + - ^(api-ref|releasenotes)/.*$ + # Keep doc/saio -- we use those sample configs in the saio playbooks + - ^doc/(requirements.txt|(manpages|s3api|source)/.*)$ + - ^test/(unit|functional|probe)/.*$ + - ^(.gitreview|.mailmap|AUTHORS|CHANGELOG)$ - swift-tox-func-s3api-ceph-s3tests-tempauth: irrelevant-files: - ^(api-ref|releasenotes)/.*$ # Keep doc/saio -- we use those sample configs in the saio playbooks # Also keep doc/s3api -- it holds known failures for these tests - ^doc/(requirements.txt|(manpages|source)/.*)$ - - ^test/(unit|probe)/.*$ + - ^test/(cors|unit|probe)/.*$ - ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$ - swift-probetests-centos-7: irrelevant-files: &probetest-irrelevant-files - ^(api-ref|releasenotes)/.*$ # Keep doc/saio -- we use those sample configs in the saio playbooks - ^doc/(requirements.txt|(manpages|s3api|source)/.*)$ - - ^test/(unit|functional)/.*$ + - ^test/(cors|unit|functional)/.*$ - ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$ - swift-probetests-centos-8: irrelevant-files: *probetest-irrelevant-files @@ -606,6 +624,7 @@ - swift-tox-func-py37 - swift-tox-func-encryption-py37 - swift-tox-func-ec-py37 + - swift-func-cors - swift-probetests-centos-7: irrelevant-files: *probetest-irrelevant-files - swift-probetests-centos-8: diff --git a/test/cors/README.rst b/test/cors/README.rst new file mode 100644 index 0000000000..40a3e8c656 --- /dev/null +++ b/test/cors/README.rst @@ -0,0 +1,97 @@ +CORS Functional Tests +===================== + +`Cross Origin Resource Sharing `__ is a bit +of a complicated beast. It focuses on the interactions between + +* a **user-agent** (typically a web browser), +* a "**source origin**" server (whose code the user-agent is running), and +* some **other server** (for our purposes, usually Swift). + +Where it gets hairy is that there may be varying degrees of trust between +these different actors. + +Fortunately, Swift `allows per-container configuration +`__ of many CORS options. +However, our normal functional tests only exercise bits and pieces of CORS, +without telling a complete story or performing a true end-to-end test. *These* +tests aim to remedy that. + +The tests consist of three parts: + +* setup + Create several test containers with well-known names, set appropriate + ACLs and CORS metadata, and upload some test objects. + +* serve + Serve a static website on localhost which, on load, will make several + CORS requests and verify expected behavior. + +* run + Use Selenium to load the website, wait for and scrape the results, and + output them in `TAP format `__. + Alternatively, open the page in your local browser and manually inspect whether + tests passed or failed. + +All of this is orchestrated through ``main.py``. It uses the standard ``OS_*`` +environment variables to determine how to connect to Swift: + +* ``OS_AUTH_URL`` (or ``ST_AUTH``) +* ``OS_USERNAME`` (or ``ST_USER``) +* ``OS_PASSWORD`` (or ``ST_KEY``) +* ``OS_STORAGE_URL`` (optional) + +.. + TODO: verify that this works with Keystone + +Running Tests Manually +---------------------- + +To inspect the test results in your local browser, run:: + + $ ./test/cors/main.py --no-run + +This will create some test containers and object in Swift, start a simple +static site, and emit a URL to visit to run the tests, like:: + + Serving test at http://localhost:8000/#OS_AUTH_URL=http://saio/auth/v1.0&OS_USERNAME=test:tester&OS_PASSWORD=testing&OS_STORAGE_URL=http://saio/v1/AUTH_test + +.. note:: + You can use ``--hostname`` and ``--port`` to adjust the origin used. + +Open the link. Toward the top of the page will be a status line; it will cycle +through the following states: + +* Loading +* Starting jobs +* Waiting for jobs to finish +* Complete + +When complete, it will also include a summary of the number of tests run as +well as pass/fail/skip counts. Below the status line will be a table of +individual tests with status, description, and additional information. + +You can also run a single test by adding a ``&test=`` query parameter. +For example:: + + http://localhost:8000/#OS_AUTH_URL=http://saio/auth/v1.0&OS_USERNAME=test:tester&OS_PASSWORD=testing&OS_STORAGE_URL=http://saio/v1/AUTH_test&test=object%20-%20GET + +will just run the test named ``object - GET``. + +To stop the server, press ``^C``. + +Running Tests with Selenium +--------------------------- + +`Selenium `__ may be used to automate visiting the +static site, waiting for tests to run, and gathering results. See the +`installation instructions `__ +for the Python bindings for more information about setting this up. + +.. note:: + On Linux, you may want to use ``xvfb-run`` to have browsers use a virtual + display. + +When using selenium, the test runner will try to run tests in Firefox, Chrome, +Safari, Edge, and IE if available; if a browser seems to not be available, its +tests will be skipped. diff --git a/test/cors/harness.js b/test/cors/harness.js new file mode 100644 index 0000000000..64a7500927 --- /dev/null +++ b/test/cors/harness.js @@ -0,0 +1,251 @@ +/* global PARAMS, XMLHttpRequest */ + +const STORAGE_URL = PARAMS.OS_STORAGE_URL || 'http://localhost:8080/v1/AUTH_test' + +function makeUrl (path) { + if (path.startsWith('http://') || path.startsWith('https://')) { + return new URL(path) + } + if (!path.startsWith('/')) { + return new URL(STORAGE_URL + '/' + path) + } + return new URL(STORAGE_URL.substr(0, STORAGE_URL.indexOf('/', 3 + STORAGE_URL.indexOf('://'))) + path) +} + +export function MakeRequest (method, path, headers, body, params) { + var url = makeUrl(path) + params = params || {} + // give each request a unique query string to avoid ever fetching from cache + params['cors-test-time'] = Date.now().toString() + params['cors-test-random'] = Math.random().toString() + for (var key in params) { + url.searchParams.append(key, params[key]) + } + return new Promise((resolve, reject) => { + const req = new XMLHttpRequest() + req.addEventListener('readystatechange', function () { + if (this.readyState === 4) { + resolve(this) + } + }) + req.open(method, url.toString()) + if (headers) { + for (const name of Object.keys(headers)) { + req.setRequestHeader(name, headers[name]) + } + } + req.send(body) + }) +} + +export function HasStatus (expectedStatus, expectedMessage) { + return function (resp) { + if (resp.status !== expectedStatus) { + throw new Error('Expected status ' + expectedStatus + ', got ' + resp.status) + } + if (resp.statusText !== expectedMessage) { + throw new Error('Expected status text ' + expectedMessage + ', got ' + resp.statusText) + } + return resp + } +} + +export function HasHeaders (headers) { + if (headers instanceof Array) { + return function (resp) { + const missing = headers.filter((h) => !resp.getResponseHeader(h)) + if (missing.length) { + throw new Error('Missing expected headers ' + JSON.stringify(missing) + ' in response: ' + resp.getAllResponseHeaders()) + } + return resp + } + } else { + return function (resp) { + const names = Object.keys(headers) + const missing = names.filter((h) => !resp.getResponseHeader(h)) + if (missing.length) { + throw new Error('Missing expected headers ' + JSON.stringify(missing) + ' in response: ' + resp.getAllResponseHeaders()) + } + for (const name of names) { + const value = resp.getResponseHeader(name) + if (name === 'Etag') { + // special case for Etag which may or may not be quoted + if ((value !== headers[name]) && (value !== "\"" + headers[name] + "\"")) { + throw new Error('Expected header ' + name + ' to have value ' + headers[name] + ', got ' + value) + } + } + else if (value !== headers[name]) { + throw new Error('Expected header ' + name + ' to have value ' + headers[name] + ', got ' + value) + } + } + return resp + } + } +} + +export function HasCommonResponseHeaders (resp) { + // These appear in most *all* responses, but have unpredictable values + HasHeaders([ + 'Last-Modified', + 'X-Openstack-Request-Id', + 'X-Timestamp', + 'X-Trans-Id', + 'Content-Type' + ])(resp) + // Save that trans-id and request-id are the same thing + if (resp.getResponseHeader('X-Trans-Id') !== resp.getResponseHeader('X-Openstack-Request-Id')) { + throw new Error('Expected X-Trans-Id and X-Openstack-Request-Id to match; got ' + resp.getAllResponseHeaders()) + } + // These appear in most responses, but *aren't* (currently) exposed via CORS + DoesNotHaveHeaders([ + 'Accept-Ranges', + 'Access-Control-Allow-Origin', + 'Access-Control-Expose-Headers', + 'Date', + // Hmmm.... + 'Content-Range', + 'X-Account-Bytes-Used', + 'X-Account-Container-Count', + 'X-Account-Object-Count', + 'X-Container-Bytes-Used', + 'X-Container-Object-Count' + ])(resp) + return resp +} + +export function DoesNotHaveHeaders (headers) { + return function (resp) { + const found = headers.filter((h) => resp.getResponseHeader(h)) + if (found.length) { + throw new Error('Found unexpected headers ' + found + ' in response: ' + resp.getAllResponseHeaders()) + } + return resp + } +} + +export function HasNoBody (resp) { + if (resp.responseText !== '') { + throw new Error('Expected no response body; got ' + resp.responseText) + } + return resp +} + +export function BodyHasLength (expectedLength) { + return (resp) => { + if (resp.responseText.length !== expectedLength) { + throw new Error('Expected body to have length ' + expectedLength + ', got ' + resp.responseText.length) + } + return resp + } +} + +export function CorsBlocked (resp) { + // Yeah, there's *nothing* useful here -- gotta look at the browser's console if you want to see what happened + HasStatus(0, '')(resp) + const allHeaders = resp.getAllResponseHeaders() + if (allHeaders !== '') { + throw new Error('Expected no headers; got ' + allHeaders) + } + HasNoBody(resp) + return resp +} + +function _denial (status, text) { + function Denial (resp) { + HasStatus(status, text)(resp) + const prefix = '

' + text + '

' + if (!resp.responseText.startsWith(prefix)) { + throw new Error('Expected body to start with ' + JSON.stringify(prefix) + '; got ' + JSON.stringify(resp.responseText)) + } + + HasHeaders({ 'Content-Type': 'text/html; charset=UTF-8' })(resp) + HasHeaders([ + 'X-Openstack-Request-Id', + 'X-Trans-Id', + 'Content-Type' + ])(resp) + if (resp.getResponseHeader('X-Trans-Id') !== resp.getResponseHeader('X-Openstack-Request-Id')) { + throw new Error('Expected X-Trans-Id and X-Openstack-Request-Id to match; got ' + resp.getAllResponseHeaders()) + } + DoesNotHaveHeaders([ + 'X-Account-Bytes-Used', + 'X-Account-Container-Count', + 'X-Account-Object-Count', + 'X-Container-Bytes-Used', + 'X-Container-Object-Count', + 'Etag', + 'X-Object-Meta-Mtime', + 'Last-Modified', + 'X-Timestamp', + 'Accept-Ranges', + 'Access-Control-Allow-Origin', + 'Access-Control-Expose-Headers', + 'Date', + // Hmmm.... + 'Content-Range' + ])(resp) + return resp + } + return Denial +} +export const Unauthorized = _denial(401, 'Unauthorized') +export const NotFound = _denial(404, 'Not Found') + +const $new = document.createElement.bind(document) + +export function Skip (msg) { + this.message = msg +} +Skip.prototype = new Error() + +const testPromises = [] +export function runTests (prefix, tests) { + for (let i = 0; i < tests.length; ++i) { + const [name, test] = tests[i] + const fullName = prefix + ' - ' + name + if ('test' in PARAMS && PARAMS['test'] !== fullName) { + continue + } + const row = document.getElementById('results').appendChild($new('tr')) + row.appendChild($new('td')).textContent = 'Queued' + row.appendChild($new('td')).textContent = fullName + row.appendChild($new('td')) + testPromises.push( + test().then((resp) => { + row.childNodes[0].className = 'pass' + row.childNodes[0].textContent = 'PASS' + }).catch((reason) => { + if (reason instanceof Skip) { + row.childNodes[0].className = 'skip' + row.childNodes[0].textContent = 'SKIP' + row.childNodes[2].textContent = reason.message + } else { + row.childNodes[0].className = 'fail' + row.childNodes[0].textContent = 'FAIL' + row.childNodes[2].textContent = reason.message || reason + if (reason.stack) { + row.childNodes[2].textContent += '\n' + reason.stack + } + throw reason + } + }) + ) + } +} + +window.addEventListener('load', function () { + document.getElementById('status').textContent = 'Waiting for all ' + testPromises.length + ' tests to finish...' + // Poor-man's version of something approximating + // Promise.allSettled(testPromises).then((results) => { + // for Firefox < 71, Chrome < 76, etc. + Promise.all(testPromises.map(x => x.then((x) => x, (x) => x))).then(() => { + const resultTable = document.getElementById('results') + document.getElementById('status').textContent = ( + 'Complete.' + + ' TESTS: ' + resultTable.childNodes.length + + ' PASS: ' + resultTable.querySelectorAll('.pass').length + + ' FAIL: ' + resultTable.querySelectorAll('.fail').length + + ' SKIP: ' + resultTable.querySelectorAll('.skip').length + ) + }) +}) diff --git a/test/cors/index.html b/test/cors/index.html new file mode 100644 index 0000000000..9844536f61 --- /dev/null +++ b/test/cors/index.html @@ -0,0 +1,42 @@ + + + + CORS Tests + + + + + + + + + + +

CORS Tests

+
Loading...
+ + + +
ResultNameDetails
+

+    
+
diff --git a/test/cors/main.py b/test/cors/main.py
new file mode 100755
index 0000000000..eb3edc3d68
--- /dev/null
+++ b/test/cors/main.py
@@ -0,0 +1,317 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2020 SwiftStack, Inc.
+#
+# 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 argparse
+import json
+import os
+import os.path
+import sys
+import threading
+import time
+
+from six.moves import urllib
+from six.moves import socketserver
+from six.moves import SimpleHTTPServer
+
+try:
+    import selenium.webdriver
+except ImportError:
+    selenium = None
+import swiftclient.client
+
+DEFAULT_ENV = {
+    'OS_AUTH_URL': os.environ.get('ST_AUTH',
+                                  'http://localhost:8080/auth/v1.0'),
+    'OS_USERNAME': os.environ.get('ST_USER', 'test:tester'),
+    'OS_PASSWORD': os.environ.get('ST_KEY', 'testing'),
+    'OS_STORAGE_URL': None,
+}
+ENV = {key: os.environ.get(key, default)
+       for key, default in DEFAULT_ENV.items()}
+
+TEST_TIMEOUT = 120.0  # seconds
+STEPS = 500
+
+
+# Hack up stdlib so SimpleHTTPRequestHandler works well on py2, too
+this_dir = os.path.realpath(os.path.dirname(__file__))
+os.getcwd = lambda: this_dir
+
+
+class CORSSiteHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
+    def log_message(self, fmt, *args):
+        pass  # quiet, you!
+
+
+class CORSSiteServer(socketserver.TCPServer):
+    allow_reuse_address = True
+
+
+class CORSSite(threading.Thread):
+    def __init__(self, bind_port=8000):
+        super(CORSSite, self).__init__()
+        self.server = None
+        self.bind_port = bind_port
+
+    def run(self):
+        self.server = CORSSiteServer(
+            ('0.0.0.0', self.bind_port),
+            CORSSiteHandler)
+        self.server.serve_forever()
+
+    def terminate(self):
+        if self.server is not None:
+            self.server.shutdown()
+            self.join()
+
+
+class Zeroes(object):
+    BUF = b'\x00' * 64 * 1024
+
+    def __init__(self, size=0):
+        self.pos = 0
+        self.size = size
+
+    def __iter__(self):
+        while self.pos < self.size:
+            chunk = self.BUF[:self.size - self.pos]
+            self.pos += len(chunk)
+            yield chunk
+
+    def __len__(self):
+        return self.size
+
+
+def setup(args):
+    conn = swiftclient.client.Connection(
+        ENV['OS_AUTH_URL'],
+        ENV['OS_USERNAME'],
+        ENV['OS_PASSWORD'],
+        timeout=5)
+    cluster_info = conn.get_capabilities()
+    conn.put_container('private', {
+        'X-Container-Read': '',
+        'X-Container-Meta-Access-Control-Allow-Origin': '',
+    })
+    conn.put_container('referrer-allowed', {
+        'X-Container-Read': '.r:%s' % args.hostname,
+        'X-Container-Meta-Access-Control-Allow-Origin': (
+            'http://%s:%d' % (args.hostname, args.port)),
+    })
+    conn.put_container('other-referrer-allowed', {
+        'X-Container-Read': '.r:other-host',
+        'X-Container-Meta-Access-Control-Allow-Origin': 'http://other-host',
+    })
+    conn.put_container('public-with-cors', {
+        'X-Container-Read': '.r:*,.rlistings',
+        'X-Container-Meta-Access-Control-Allow-Origin': '*',
+    })
+    conn.put_container('private-with-cors', {
+        'X-Container-Read': '',
+        'X-Container-Meta-Access-Control-Allow-Origin': '*',
+    })
+    conn.put_container('public-no-cors', {
+        'X-Container-Read': '.r:*,.rlistings',
+        'X-Container-Meta-Access-Control-Allow-Origin': '',
+    })
+    conn.put_container('public-segments', {
+        'X-Container-Read': '.r:*',
+        'X-Container-Meta-Access-Control-Allow-Origin': '',
+    })
+
+    for container in ('private', 'referrer-allowed', 'other-referrer-allowed',
+                      'public-with-cors', 'private-with-cors',
+                      'public-no-cors'):
+        conn.put_object(container, 'obj', Zeroes(1024), headers={
+            'X-Object-Meta-Mtime': str(time.time())})
+    for n in range(10):
+        segment_etag = conn.put_object(
+            'public-segments', 'seg%02d' % n, Zeroes(1024 * 1024),
+            headers={'Content-Type': 'application/swiftclient-segment'})
+        conn.put_object(
+            'public-with-cors', 'dlo/seg%02d' % n, Zeroes(1024 * 1024),
+            headers={'Content-Type': 'application/swiftclient-segment'})
+    conn.put_object('public-with-cors', 'dlo-with-unlistable-segments', b'',
+                    headers={'X-Object-Manifest': 'public-segments/seg'})
+    conn.put_object('public-with-cors', 'dlo', b'',
+                    headers={'X-Object-Manifest': 'public-with-cors/dlo/seg'})
+
+    if 'slo' in cluster_info:
+        conn.put_object('public-with-cors', 'slo', json.dumps([
+            {'path': 'public-segments/seg%02d' % n, 'etag': segment_etag}
+            for n in range(10)]), query_string='multipart-manifest=put')
+
+    if 'symlink' in cluster_info:
+        for tgt in ('private', 'public-with-cors', 'public-no-cors'):
+            conn.put_object('public-with-cors', 'symlink-to-' + tgt, b'',
+                            headers={'X-Symlink-Target': tgt + '/obj'})
+
+
+def get_results_table(browser):
+    result_table = browser.find_element_by_id('results')
+    for row in result_table.find_elements_by_xpath('./tr'):
+        cells = row.find_elements_by_xpath('td')
+        yield (
+            cells[0].text,
+            browser.name + ': ' + cells[1].text,
+            cells[2].text)
+
+
+def run(args, url):
+    results = []
+    browsers = list(ALL_BROWSERS) if 'all' in args.browsers else args.browsers
+    ran_one = False
+    for browser_name in browsers:
+        driver = getattr(selenium.webdriver, browser_name.title())
+        try:
+            browser = driver()
+        except Exception as e:
+            results.append(('SKIP', browser_name, str(e).strip()))
+            continue
+        ran_one = True
+        try:
+            browser.get(url)
+
+            start = time.time()
+            for _ in range(STEPS):
+                status = browser.find_element_by_id('status').text
+                if status.startswith('Complete'):
+                    results.extend(get_results_table(browser))
+                    break
+                time.sleep(TEST_TIMEOUT / STEPS)
+            else:
+                try:
+                    results.extend(get_results_table(browser))
+                except Exception:
+                    pass  # worth a shot
+                # that took a sec; give it *one last chance* to succeed
+                status = browser.find_element_by_id('status').text
+                if not status.startswith('Complete'):
+                    results.append((
+                        'ERROR', browser_name, 'Timed out (%s)' % status))
+                    continue
+            sys.stderr.write('Tested %s in %.1fs\n' % (
+                browser_name, time.time() - start))
+        except Exception as e:
+            results.append(('ERROR', browser_name, str(e).strip()))
+        finally:
+            browser.close()
+
+    if args.output is not None:
+        fp = open(args.output, 'w')
+    else:
+        fp = sys.stdout
+
+    fp.write('1..%d\n' % len(results))
+    rc = 0
+    if not ran_one:
+        rc += 1  # make sure "no tests ran" translates to "failed"
+    for test, (status, name, details) in enumerate(results, start=1):
+        if status == 'PASS':
+            fp.write('ok %d - %s\n' % (test, name))
+        elif status == 'SKIP':
+            fp.write('ok %d - %s # skip %s\n' % (test, name, details))
+        else:
+            fp.write('not ok %d - %s\n' % (test, name))
+            fp.write('  %s%s\n' % (status, ':' if details else ''))
+            if details:
+                fp.write(''.join(
+                    '  ' + line + '\n'
+                    for line in details.split('\n')))
+            rc += 1
+
+    if fp is not sys.stdout:
+        fp.close()
+
+    return rc
+
+
+ALL_BROWSERS = [
+    'firefox',
+    'chrome',
+    'safari',
+    'edge',
+    'ie',
+]
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        description='Set up and run CORS functional tests',
+        epilog='''The tests consist of three parts:
+
+setup - Create several test containers with well-known names, set appropriate
+        ACLs and CORS metadata, and upload some test objects.
+serve - Serve a static website on localhost which, on load, will make several
+        CORS requests and verify expected behavior.
+run   - Use Selenium to load the website, wait for and scrape the results,
+        and output them in TAP format.
+
+By default, perform all three parts. You can skip some or all of the parts
+with the --no-setup, --no-serve, and --no-run options.
+''')
+    parser.add_argument('-P', '--port', type=int, default=8000)
+    parser.add_argument('-H', '--hostname', default='localhost')
+    parser.add_argument('--no-setup', action='store_true')
+    parser.add_argument('--no-serve', action='store_true')
+    parser.add_argument('--no-run', action='store_true')
+    parser.add_argument('-o', '--output')
+    parser.add_argument('browsers', nargs='*',
+                        default='all',
+                        choices=['all'] + ALL_BROWSERS)
+    args = parser.parse_args()
+    if not args.no_setup:
+        setup(args)
+
+    if args.no_serve:
+        site = None
+    else:
+        site = CORSSite(args.port)
+
+    should_run = not args.no_run
+    if should_run and not selenium:
+        print('Selenium not available; cannot run tests automatically')
+        should_run = False
+
+    if ENV['OS_STORAGE_URL'] is None:
+        ENV['OS_STORAGE_URL'] = swiftclient.client.get_auth(
+            ENV['OS_AUTH_URL'],
+            ENV['OS_USERNAME'],
+            ENV['OS_PASSWORD'],
+            timeout=1)[0]
+
+    url = 'http://%s:%d/#%s' % (args.hostname, args.port, '&'.join(
+        '%s=%s' % (urllib.parse.quote(key), urllib.parse.quote(val))
+        for key, val in ENV.items()))
+
+    rc = 0
+    if should_run:
+        if site:
+            site.start()
+        try:
+            rc = run(args, url)
+        finally:
+            if site:
+                site.terminate()
+    else:
+        if site:
+            print('Serving test at %s' % url)
+            try:
+                site.run()
+            except KeyboardInterrupt:
+                pass
+    exit(rc)
diff --git a/test/cors/test-account.js b/test/cors/test-account.js
new file mode 100644
index 0000000000..b106ed76d0
--- /dev/null
+++ b/test/cors/test-account.js
@@ -0,0 +1,16 @@
+import { runTests, MakeRequest, CorsBlocked } from './harness.js'
+
+runTests('account', [
+  ['GET', () => MakeRequest('GET', '')
+    // 200, but missing Access-Control-Allow-Origin
+    .then(CorsBlocked)],
+  ['HEAD', () => MakeRequest('HEAD', '')
+    // 200, but missing Access-Control-Allow-Origin
+    .then(CorsBlocked)],
+  ['POST', () => MakeRequest('POST', '')
+    // 200, but missing Access-Control-Allow-Origin
+    .then(CorsBlocked)],
+  ['POST with meta', () => MakeRequest('POST', '', { 'X-Account-Meta-Never-Makes-It': 'preflight failed' })
+    // preflight 200s, but it's missing Access-Control-Allow-Origin
+    .then(CorsBlocked)]
+])
diff --git a/test/cors/test-container.js b/test/cors/test-container.js
new file mode 100644
index 0000000000..561d5f2731
--- /dev/null
+++ b/test/cors/test-container.js
@@ -0,0 +1,148 @@
+import {
+  runTests,
+  MakeRequest,
+  HasStatus,
+  HasHeaders,
+  HasCommonResponseHeaders,
+  HasNoBody
+} from './harness.js'
+
+function CheckJsonListing (resp) {
+  HasHeaders({ 'Content-Type': 'application/json; charset=utf-8' })(resp)
+  const listing = JSON.parse(resp.responseText)
+  for (const item of listing) {
+    if ('subdir' in item) {
+      if (Object.keys(item).length !== 1) {
+        throw new Error('Expected subdir to be the only key, got ' + JSON.stringify(item))
+      }
+      continue
+    }
+    const missing = ['name', 'bytes', 'content_type', 'hash', 'last_modified'].filter((key) => !(key in item))
+    if (missing.length) {
+      throw new Error('Listing item is missing expected keys ' + JSON.stringify(missing) + '; got ' + JSON.stringify(item))
+    }
+  }
+  return listing
+}
+
+function HasStatus200Or204 (resp) {
+  if (resp.status === 200) {
+    // NB: some browsers (like chrome) may serve HEADs from cached GETs, leading to the 200
+    HasStatus(200, 'OK')(resp)
+  } else {
+    HasStatus(204, 'No Content')(resp)
+  }
+  return resp
+}
+
+const expectedListing = [
+  'dlo',
+  'dlo-with-unlistable-segments',
+  'dlo/seg00',
+  'dlo/seg01',
+  'dlo/seg02',
+  'dlo/seg03',
+  'dlo/seg04',
+  'dlo/seg05',
+  'dlo/seg06',
+  'dlo/seg07',
+  'dlo/seg08',
+  'dlo/seg09',
+  'obj',
+  'slo',
+  'symlink-to-private',
+  'symlink-to-public-no-cors',
+  'symlink-to-public-with-cors'
+]
+const expectedWithDelimiter = [
+  'dlo',
+  'dlo-with-unlistable-segments',
+  'dlo/',
+  'obj',
+  'slo',
+  'symlink-to-private',
+  'symlink-to-public-no-cors',
+  'symlink-to-public-with-cors'
+]
+
+runTests('container', [
+  ['GET format=txt',
+    () => MakeRequest('GET', 'public-with-cors', {}, '', {'format': 'txt'})
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }))
+      .then((resp) => {
+        const names = resp.responseText.split('\n')
+        if (!(names.length === expectedListing.length + 1 && names.every((name, i) => name === (i === expectedListing.length ? '' : expectedListing[i])))) {
+          throw new Error('Expected listing to have items ' + JSON.stringify(expectedListing) + '; got ' + JSON.stringify(names))
+        }
+      })],
+  ['GET format=json',
+    () => MakeRequest('GET', 'public-with-cors', {}, '', {'format': 'json'})
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(CheckJsonListing)
+      .then((listing) => {
+        const names = listing.map((item) => 'subdir' in item ? item.subdir : item.name)
+        if (!(names.length === expectedListing.length && names.every((name, i) => expectedListing[i] === name))) {
+          throw new Error('Expected listing to have items ' + JSON.stringify(expectedListing) + '; got ' + JSON.stringify(names))
+        }
+      })],
+  ['GET format=json&delimiter=/',
+    () => MakeRequest('GET', 'public-with-cors', {}, '', {'format': 'json', 'delimiter': '/'})
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(CheckJsonListing)
+      .then((listing) => {
+        const names = listing.map((item) => 'subdir' in item ? item.subdir : item.name)
+        if (!(names.length === expectedWithDelimiter.length && names.every((name, i) => expectedWithDelimiter[i] === name))) {
+          throw new Error('Expected listing to have items ' + JSON.stringify(expectedWithDelimiter) + '; got ' + JSON.stringify(names))
+        }
+      })],
+  ['GET format=xml',
+    () => MakeRequest('GET', 'public-with-cors', {}, '', {'format': 'xml'})
+      .then(HasStatus(200, 'OK'))
+      .then(HasHeaders({ 'Content-Type': 'application/xml; charset=utf-8' }))
+      .then((resp) => {
+        const prefix = '\n'
+        if (resp.responseText.substr(0, prefix.length) !== prefix) {
+          throw new Error('Expected response to start with ' + JSON.stringify(prefix) + '; got ' + resp.responseText)
+        }
+      })],
+  ['GET Accept: json',
+    () => MakeRequest('GET', 'public-with-cors', { Accept: 'application/json' })
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(CheckJsonListing)
+      .then((listing) => {
+        if (listing.length !== 17) {
+          throw new Error('Expected exactly 17 items in listing; got ' + listing.length)
+        }
+      })],
+  ['GET Accept: xml',
+    // NB: flakey on Safari -- sometimes it serves JSON from cache, *even with* a Vary: Accept header
+    () => MakeRequest('GET', 'public-with-cors', { Accept: 'application/xml' })
+      .then(HasStatus(200, 'OK'))
+      .then(HasHeaders({ 'Content-Type': 'application/xml; charset=utf-8' }))
+      .then((resp) => {
+        const prefix = '\n'
+        if (resp.responseText.substr(0, prefix.length) !== prefix) {
+          throw new Error('Expected response to start with ' + JSON.stringify(prefix) + '; got ' + resp.responseText)
+        }
+      })],
+  ['HEAD format=txt',
+    () => MakeRequest('HEAD', 'public-with-cors', {}, '', {'format': 'txt'})
+      .then(HasStatus200Or204)
+      .then(HasHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }))
+      .then(HasNoBody)],
+  ['HEAD format=json',
+    () => MakeRequest('HEAD', 'public-with-cors', {}, '', {'format': 'json'})
+      .then(HasStatus200Or204)
+      .then(HasHeaders({ 'Content-Type': 'application/json; charset=utf-8' }))
+      .then(HasNoBody)],
+  ['HEAD format=xml',
+    () => MakeRequest('HEAD', 'public-with-cors', {}, '', {'format': 'xml'})
+      .then(HasStatus200Or204)
+      .then(HasHeaders({ 'Content-Type': 'application/xml; charset=utf-8' }))
+      .then(HasNoBody)]
+])
diff --git a/test/cors/test-info.js b/test/cors/test-info.js
new file mode 100644
index 0000000000..903dbba05c
--- /dev/null
+++ b/test/cors/test-info.js
@@ -0,0 +1,60 @@
+import {
+  runTests,
+  MakeRequest,
+  HasStatus,
+  HasHeaders,
+  DoesNotHaveHeaders,
+  HasNoBody,
+  CorsBlocked
+} from './harness.js'
+
+function CheckInfoHeaders (resp) {
+  return Promise.resolve(resp)
+    .then(HasHeaders({ 'Content-Type': 'application/json; charset=UTF-8' }))
+    .then(HasHeaders(['X-Trans-Id']))
+    .then(DoesNotHaveHeaders([
+      'X-Openstack-Request-Id', // TODO: this is blocked by CORS but almost certainly shouldn't
+      'X-Timestamp',
+      'Accept-Ranges',
+      'Access-Control-Allow-Origin',
+      'Access-Control-Expose-Headers',
+      'Date',
+      'Content-Range'
+    ]))
+}
+function CheckInfoBody (resp) {
+  const clusterInfo = JSON.parse(resp.responseText)
+  if (!('swift' in clusterInfo)) {
+    throw new Error('Expected to find "swift" in /info response; ' +
+                    'got ' + JSON.stringify(clusterInfo))
+  }
+  if (!('version' in clusterInfo.swift)) {
+    throw new Error('Expected to find "swift.version" in /info response; ' +
+                    'got ' + JSON.stringify(clusterInfo.swift))
+  }
+  console.log('Tested against Swift version ' + clusterInfo.swift.version)
+  return clusterInfo
+}
+
+export const GetClusterInfo = MakeRequest('GET', '/info')
+  .then(HasStatus(200, 'OK'))
+  .then(CheckInfoHeaders)
+  .then(CheckInfoBody)
+
+// TODO: /info should probably get an automatic access-control-allow-origin: *
+runTests('cluster info', [
+  ['GET', () => GetClusterInfo],
+  ['GET with header', () => MakeRequest('GET', '/info', { 'X-Trans-Id-Extra': 'my-tracker' })
+    // 200, but missing Access-Control-Allow-Origin
+    .then(CorsBlocked)],
+  ['HEAD', () => MakeRequest('HEAD', '/info')
+    .then(HasStatus(200, 'OK'))
+    .then(CheckInfoHeaders)
+    .then(HasNoBody)],
+  ['OPTIONS', () => MakeRequest('OPTIONS', '/info')
+    // 200, but missing Access-Control-Allow-Origin
+    .then(CorsBlocked)],
+  ['POST', () => MakeRequest('POST', '/info')
+    // 405, but missing Access-Control-Allow-Origin
+    .then(CorsBlocked)]
+])
diff --git a/test/cors/test-large-objects.js b/test/cors/test-large-objects.js
new file mode 100644
index 0000000000..11af1974ff
--- /dev/null
+++ b/test/cors/test-large-objects.js
@@ -0,0 +1,93 @@
+import {
+  runTests,
+  MakeRequest,
+  HasStatus,
+  HasHeaders,
+  HasCommonResponseHeaders,
+  DoesNotHaveHeaders,
+  HasNoBody,
+  CorsBlocked,
+  Skip
+} from './harness.js'
+import { GetClusterInfo } from './test-info.js'
+
+function MakeSloRequest () {
+  return GetClusterInfo.then((clusterInfo) => {
+    if (!('slo' in clusterInfo)) {
+      throw new Skip('SLO is not enabled')
+    }
+    return MakeRequest(...arguments)
+  })
+}
+
+runTests('large object', [
+  ['GET DLO',
+    () => MakeRequest('GET', 'public-with-cors/dlo')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        ETag: '"8d431e7531abb83a6cf67e56d91c6f74"'
+      }))
+      .then(DoesNotHaveHeaders(['X-Object-Manifest'])) // TODO: should maybe be exposed
+      .then((resp) => {
+        if (resp.responseText.length !== 10485760) {
+          throw new Error('Expected body to have length 10485760, got ' + resp.responseText.length)
+        }
+      })],
+  ['GET DLO with unlistable segments',
+    () => MakeRequest('GET', 'public-with-cors/dlo-with-unlistable-segments')
+      .then(CorsBlocked)], // TODO: should probably be Unauthorized
+  ['GET SLO',
+    () => MakeSloRequest('GET', 'public-with-cors/slo')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        ETag: '"8d431e7531abb83a6cf67e56d91c6f74"'
+      }))
+      .then(DoesNotHaveHeaders(['X-Static-Large-Object'])) // TODO: should maybe be exposed
+      .then((resp) => {
+        if (resp.responseText.length !== 10485760) {
+          throw new Error('Expected body to have length 10485760, got ' + resp.responseText.length)
+        }
+      })],
+  ['HEAD SLO',
+    () => MakeSloRequest('HEAD', 'public-with-cors/slo')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        ETag: '"8d431e7531abb83a6cf67e56d91c6f74"'
+      }))
+      .then(DoesNotHaveHeaders(['X-Static-Large-Object'])) // TODO: should maybe be exposed
+      .then(HasNoBody)],
+  ['GET SLO Range',
+    () => MakeSloRequest('GET', 'public-with-cors/slo', { Range: 'bytes=100-199' })
+      .then(HasStatus(206, 'Partial Content'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        ETag: '"8d431e7531abb83a6cf67e56d91c6f74"'
+      }))
+      .then(DoesNotHaveHeaders(['X-Static-Large-Object'])) // TODO: should maybe be exposed
+      .then((resp) => {
+        if (resp.responseText.length !== 100) {
+          throw new Error('Expected body to have length 100, got ' + resp.responseText.length)
+        }
+      })],
+  ['GET SLO Suffix Range',
+    () => MakeSloRequest('GET', 'public-with-cors/slo', { Range: 'bytes=-100' })
+      .then(HasStatus(206, 'Partial Content'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        ETag: '"8d431e7531abb83a6cf67e56d91c6f74"'
+      }))
+      .then(DoesNotHaveHeaders(['X-Static-Large-Object'])) // TODO: should maybe be exposed
+      .then((resp) => {
+        if (resp.responseText.length !== 100) {
+          throw new Error('Expected body to have length 100, got ' + resp.responseText.length)
+        }
+      })]
+])
diff --git a/test/cors/test-object.js b/test/cors/test-object.js
new file mode 100644
index 0000000000..f2cbe7b8dc
--- /dev/null
+++ b/test/cors/test-object.js
@@ -0,0 +1,169 @@
+import {
+  runTests,
+  MakeRequest,
+  HasStatus,
+  HasHeaders,
+  HasCommonResponseHeaders,
+  HasNoBody,
+  BodyHasLength,
+  CorsBlocked,
+  NotFound,
+  Unauthorized
+} from './harness.js'
+
+runTests('object', [
+  ['GET',
+    () => MakeRequest('GET', 'public-with-cors/obj')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(BodyHasLength(1024))],
+  ['HEAD',
+    () => MakeRequest('HEAD', 'public-with-cors/obj')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({ 'Content-Type': 'application/octet-stream' }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(HasNoBody)],
+  ['GET Range',
+    () => MakeRequest('GET', 'public-with-cors/obj', { Range: 'bytes=100-199' })
+      .then(HasStatus(206, 'Partial Content'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(BodyHasLength(100))],
+  ['GET If-Match matching',
+    () => MakeRequest('GET', 'public-with-cors/obj', { 'If-Match': '0f343b0931126a20f133d67c2b018a3b' })
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(BodyHasLength(1024))],
+  ['GET If-Match not matching',
+    () => MakeRequest('GET', 'public-with-cors/obj', { 'If-Match': 'something-else' })
+      .then(HasStatus(412, 'Precondition Failed'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'text/html; charset=UTF-8',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(HasNoBody)],
+  ['GET If-None-Match matching',
+    () => MakeRequest('GET', 'public-with-cors/obj', { 'If-None-Match': '0f343b0931126a20f133d67c2b018a3b' })
+      .then(HasStatus(304, 'Not Modified'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        // TODO: Content-Type can vary depending on storage policy type...
+        // 'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['Content-Type', 'X-Object-Meta-Mtime']))
+      .then(HasNoBody)],
+  ['GET If-None-Match not matching',
+    () => MakeRequest('GET', 'public-with-cors/obj', { 'If-None-Match': 'something-else' })
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(BodyHasLength(1024))],
+  ['GET not found',
+    () => MakeRequest('GET', 'public-with-cors/should-404')
+      .then(NotFound)],
+  ['POST',
+    () => MakeRequest('POST', 'public-with-cors/obj')
+      // No good way to make a container publicly-writable
+      .then(Unauthorized)],
+  ['POST with meta',
+    () => MakeRequest('POST', 'public-with-cors/obj', { 'X-Object-Meta-Foo': 'bar' })
+      // Still no good way to make a container publicly-writable, but notably,
+      // *the POST goes through* and this isn't just CorsBlocked
+      .then(Unauthorized)],
+  ['GET no CORS, object exists',
+    () => MakeRequest('GET', 'public-no-cors/obj')
+      .then(CorsBlocked)], // But req 200s
+  ['GET no CORS, object does not exist',
+    () => MakeRequest('GET', 'public-no-cors/should-404')
+      .then(CorsBlocked)], // But req 404s
+  ['GET Range no CORS',
+    () => MakeRequest('GET', 'public-no-cors/obj', { Range: 'bytes=100-199' })
+      .then(CorsBlocked)], // preflight fails
+  ['GET other-referrer, object exists',
+    () => MakeRequest('GET', 'other-referrer-allowed/obj')
+      .then(CorsBlocked)], // But req 401s
+  ['GET other-referrer, object does not exist',
+    () => MakeRequest('GET', 'other-referrer-allowed/should-404')
+      .then(CorsBlocked)], // But req 401s
+  ['GET Range other-referrer',
+    () => MakeRequest('GET', 'other-referrer-allowed/obj', { Range: 'bytes=100-199' })
+      .then(CorsBlocked)], // preflight fails
+  ['GET other-referrer, attempt to spoof referer',
+    () => MakeRequest('GET', 'other-referrer-allowed/obj', { Referer: 'https://other-host' })
+      .then(CorsBlocked)], // new header gets ignored, req 401s with no allow-origin
+  ['GET no ACL, object exists',
+    () => MakeRequest('GET', 'private-with-cors/obj')
+      .then(Unauthorized)],
+  ['GET no ACL, object does not exist',
+    () => MakeRequest('GET', 'private-with-cors/would-404')
+      .then(Unauthorized)],
+  ['GET completely private',
+    () => MakeRequest('GET', 'private/obj')
+      .then(CorsBlocked)],
+  ['GET Range completely private',
+    () => MakeRequest('GET', 'private/obj', { Range: 'bytes=100-199' })
+      .then(CorsBlocked)],
+  ['GET referrer allowed',
+    () => MakeRequest('GET', 'referrer-allowed/obj')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(BodyHasLength(1024))],
+  ['HEAD referrer allowed',
+    () => MakeRequest('HEAD', 'referrer-allowed/obj')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(HasNoBody)],
+  ['GET Range referrer allowed',
+    () => MakeRequest('GET', 'referrer-allowed/obj', { Range: 'bytes=100-199' })
+      .then(HasStatus(206, 'Partial Content'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(BodyHasLength(100))],
+  ['GET attempt to spoof referer',
+    () => MakeRequest('GET', 'referrer-allowed/obj', { Referer: 'https://other-host' })
+      // new header gets ignored, no preflight, get succeeds
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(BodyHasLength(1024))]
+])
diff --git a/test/cors/test-symlink.js b/test/cors/test-symlink.js
new file mode 100644
index 0000000000..ed6781237f
--- /dev/null
+++ b/test/cors/test-symlink.js
@@ -0,0 +1,139 @@
+import {
+  runTests,
+  MakeRequest,
+  HasStatus,
+  HasHeaders,
+  HasCommonResponseHeaders,
+  DoesNotHaveHeaders,
+  HasNoBody,
+  CorsBlocked,
+  Skip
+} from './harness.js'
+import { GetClusterInfo } from './test-info.js'
+
+function MakeSymlinkRequest () {
+  return GetClusterInfo.then((clusterInfo) => {
+    if (!('symlink' in clusterInfo)) {
+      throw new Skip('Symlink is not enabled')
+    }
+    return MakeRequest(...arguments)
+  })
+}
+
+runTests('symlink', [
+  ['GET link to no CORS',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-no-cors')
+      .then(CorsBlocked)],
+  ['HEAD link to no CORS',
+    () => MakeSymlinkRequest('HEAD', 'public-with-cors/symlink-to-public-no-cors')
+      .then(CorsBlocked)],
+  ['GET Range link to no CORS',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-no-cors', { Range: 'bytes=100-199' })
+      .then(CorsBlocked)], // But preflight *succeeded*!
+
+  ['GET link with CORS',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-with-cors')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(DoesNotHaveHeaders(['Content-Location']))
+      .then((resp) => {
+        if (resp.responseText.length !== 1024) {
+          throw new Error('Expected body to have length 1024, got ' + resp.responseText.length)
+        }
+      })],
+  ['HEAD link with CORS',
+    () => MakeSymlinkRequest('HEAD', 'public-with-cors/symlink-to-public-with-cors')
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(DoesNotHaveHeaders(['Content-Location']))
+      .then(HasNoBody)],
+  ['GET Range link with CORS',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-with-cors', { Range: 'bytes=100-199' })
+      .then(HasStatus(206, 'Partial Content'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(DoesNotHaveHeaders(['Content-Location']))
+      .then((resp) => {
+        if (resp.responseText.length !== 100) {
+          throw new Error('Expected body to have length 100, got ' + resp.responseText.length)
+        }
+      })],
+
+  ['GET private',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-private')
+      .then(CorsBlocked)], // TODO: maybe should be Unauthorized?
+  ['HEAD private',
+    () => MakeSymlinkRequest('HEAD', 'public-with-cors/symlink-to-private')
+      .then(CorsBlocked)], // TODO: maybe should be Unauthorized?
+  ['GET private Range',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-private', { Range: 'bytes=100-199' })
+      .then(CorsBlocked)], // TODO: maybe should be Unauthorized?
+
+  ['GET If-Match matching',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-with-cors', { 'If-Match': '0f343b0931126a20f133d67c2b018a3b' })
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(DoesNotHaveHeaders(['Content-Location']))
+      .then((resp) => {
+        if (resp.responseText.length !== 1024) {
+          throw new Error('Expected body to have length 1024, got ' + resp.responseText.length)
+        }
+      })],
+  ['GET If-Match not matching',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-with-cors', { 'If-Match': 'something-else' })
+      .then(HasStatus(412, 'Precondition Failed'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'text/html; charset=UTF-8',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(DoesNotHaveHeaders(['Content-Location']))
+      .then(HasNoBody)],
+  ['GET If-None-Match matching',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-with-cors', { 'If-None-Match': '0f343b0931126a20f133d67c2b018a3b' })
+      .then(HasStatus(304, 'Not Modified'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        // Content-Type can vary depending on storage policy type...
+        // 'Content-Type': 'text/html; charset=UTF-8',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['Content-Type', 'X-Object-Meta-Mtime']))
+      .then(DoesNotHaveHeaders(['Content-Location']))
+      .then(HasNoBody)],
+  ['GET If-None-Match not matching',
+    () => MakeSymlinkRequest('GET', 'public-with-cors/symlink-to-public-with-cors', { 'If-None-Match': 'something-else' })
+      .then(HasStatus(200, 'OK'))
+      .then(HasCommonResponseHeaders)
+      .then(HasHeaders({
+        'Content-Type': 'application/octet-stream',
+        Etag: '0f343b0931126a20f133d67c2b018a3b'
+      }))
+      .then(HasHeaders(['X-Object-Meta-Mtime']))
+      .then(DoesNotHaveHeaders(['Content-Location']))
+      .then((resp) => {
+        if (resp.responseText.length !== 1024) {
+          throw new Error('Expected body to have length 1024, got ' + resp.responseText.length)
+        }
+      })]
+])
diff --git a/tools/playbooks/cors/install_selenium.yaml b/tools/playbooks/cors/install_selenium.yaml
new file mode 100644
index 0000000000..682c36a875
--- /dev/null
+++ b/tools/playbooks/cors/install_selenium.yaml
@@ -0,0 +1,30 @@
+- hosts: all
+  become: true
+  tasks:
+    - name: install virtual frame buffer
+      yum:
+        name: xorg-x11-server-Xvfb
+        state: present
+    - name: install selenium
+      pip:
+        name: selenium
+        state: present
+    - name: install firefox
+      yum:
+        name: firefox
+        state: present
+    - name: fetch firefox driver
+      get_url:
+        url: https://github.com/mozilla/geckodriver/releases/download/v0.26.0/geckodriver-v0.26.0-linux64.tar.gz
+        dest: /tmp/geckodriver.tar.gz
+    - name: unpack firefox driver
+      unarchive:
+        src: /tmp/geckodriver.tar.gz
+        dest: /usr/local/bin
+        remote_src: true
+    - name: check firefox version
+      command: firefox --version
+    #- name: install chromium
+    #  yum:
+    #    name: chromium-headless
+    #    state: present
diff --git a/tools/playbooks/cors/post.yaml b/tools/playbooks/cors/post.yaml
new file mode 100644
index 0000000000..b0e4ba438d
--- /dev/null
+++ b/tools/playbooks/cors/post.yaml
@@ -0,0 +1,25 @@
+- hosts: all
+  become: true
+  tasks:
+    - name: Copy geckodriver log from worker nodes to executor node
+      synchronize:
+        src: '{{ ansible_env.HOME }}/geckodriver.log'
+        dest: '{{ zuul.executor.log_root }}'
+        mode: pull
+        copy_links: true
+        verify_host: true
+
+    - name: Copy CORS tests output from worker nodes to executor node
+      synchronize:
+        src: '{{ ansible_env.HOME }}/cors-test-results.txt'
+        dest: '{{ zuul.executor.log_root }}'
+        mode: pull
+        copy_links: true
+        verify_host: true
+
+    - zuul_return:
+        data:
+          zuul:
+            artifacts:
+              - name: CORS test results
+                url: cors-test-results.txt
diff --git a/tools/playbooks/cors/run.yaml b/tools/playbooks/cors/run.yaml
new file mode 100644
index 0000000000..a6076f82d9
--- /dev/null
+++ b/tools/playbooks/cors/run.yaml
@@ -0,0 +1,15 @@
+- hosts: all
+  tasks:
+    - name: Shutdown main swift services
+      shell: "swift-init stop main"
+      ignore_errors: true
+
+    - name: Start main swift services
+      shell: "swift-init start main"
+
+    - name: Run CORS tests
+      shell: >
+        xvfb-run python
+        {{ ansible_env.HOME }}/{{ zuul.project.src_dir }}/test/cors/main.py
+        --output {{ ansible_env.HOME }}/cors-test-results.txt
+        all