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
This commit is contained in:
parent
5c3eb488f2
commit
c5152ed4d3
27
.zuul.yaml
27
.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:
|
||||
|
97
test/cors/README.rst
Normal file
97
test/cors/README.rst
Normal file
@ -0,0 +1,97 @@
|
||||
CORS Functional Tests
|
||||
=====================
|
||||
|
||||
`Cross Origin Resource Sharing <https://www.w3.org/TR/cors/>`__ 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
|
||||
<https://docs.openstack.org/swift/latest/cors.html>`__ 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 <http://testanything.org/tap-specification.html>`__.
|
||||
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=<name>`` 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 <https://www.selenium.dev/>`__ may be used to automate visiting the
|
||||
static site, waiting for tests to run, and gathering results. See the
|
||||
`installation instructions <https://selenium-python.readthedocs.io/installation.html>`__
|
||||
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.
|
251
test/cors/harness.js
Normal file
251
test/cors/harness.js
Normal file
@ -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 = '<html><h1>' + text + '</h1>'
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
42
test/cors/index.html
Normal file
42
test/cors/index.html
Normal file
@ -0,0 +1,42 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>CORS Tests</title>
|
||||
<style type="text/css">
|
||||
tr:nth-child(2n) { background: lightgrey; }
|
||||
.pass { background: green; }
|
||||
.fail { background: red; }
|
||||
.skip { background: orange; }
|
||||
td:nth-child(1) {
|
||||
padding: .1em;
|
||||
width: 8em;
|
||||
text-align: center;
|
||||
}
|
||||
td:nth-child(2) {
|
||||
width: 50%;
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
const PARAMS = !window.location.hash ? {} : window.location.hash.substr(1)
|
||||
.split('&')
|
||||
.map(v => v.split('='))
|
||||
.reduce( (acc, [key, val]) => ({ ...acc, [unescape(key)]: unescape(val) }), {})
|
||||
console.log(PARAMS)
|
||||
</script>
|
||||
<script type="module" src="test-info.js"></script>
|
||||
<script type="module" src="test-account.js"></script>
|
||||
<script type="module" src="test-container.js"></script>
|
||||
<script type="module" src="test-object.js"></script>
|
||||
<script type="module" src="test-large-objects.js"></script>
|
||||
<script type="module" src="test-symlink.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h2>CORS Tests</h2>
|
||||
<div id="status">Loading...</div>
|
||||
<table>
|
||||
<thead><th>Result</th><th>Name</th><th>Details</th></thead>
|
||||
<tbody id="results"></tbody>
|
||||
</table>
|
||||
<pre id="dumper"></pre>
|
||||
</body>
|
||||
</html>
|
317
test/cors/main.py
Executable file
317
test/cors/main.py
Executable file
@ -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)
|
16
test/cors/test-account.js
Normal file
16
test/cors/test-account.js
Normal file
@ -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)]
|
||||
])
|
148
test/cors/test-container.js
Normal file
148
test/cors/test-container.js
Normal file
@ -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 = '<?xml version="1.0" encoding="UTF-8"?>\n<container name="public-with-cors">'
|
||||
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 = '<?xml version="1.0" encoding="UTF-8"?>\n<container name="public-with-cors">'
|
||||
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)]
|
||||
])
|
60
test/cors/test-info.js
Normal file
60
test/cors/test-info.js
Normal file
@ -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)]
|
||||
])
|
93
test/cors/test-large-objects.js
Normal file
93
test/cors/test-large-objects.js
Normal file
@ -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)
|
||||
}
|
||||
})]
|
||||
])
|
169
test/cors/test-object.js
Normal file
169
test/cors/test-object.js
Normal file
@ -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))]
|
||||
])
|
139
test/cors/test-symlink.js
Normal file
139
test/cors/test-symlink.js
Normal file
@ -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)
|
||||
}
|
||||
})]
|
||||
])
|
30
tools/playbooks/cors/install_selenium.yaml
Normal file
30
tools/playbooks/cors/install_selenium.yaml
Normal file
@ -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
|
25
tools/playbooks/cors/post.yaml
Normal file
25
tools/playbooks/cors/post.yaml
Normal file
@ -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
|
15
tools/playbooks/cors/run.yaml
Normal file
15
tools/playbooks/cors/run.yaml
Normal file
@ -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
|
Loading…
Reference in New Issue
Block a user