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...
+
+
+
+
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