From c5152ed4d3a37c5e0017be9f0445bf0868561b96 Mon Sep 17 00:00:00 2001 From: Tim Burke Date: 2018年1月11日 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
+ +

AltStyle によって変換されたページ (->オリジナル) /

+ 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='/index.cgi/contrast/https://opendev.org/openstack/swift/commit/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