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:
|
vars:
|
||||||
bindep_profile: test py36
|
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:
|
- nodeset:
|
||||||
name: swift-five-nodes
|
name: swift-five-nodes
|
||||||
nodes:
|
nodes:
|
||||||
@ -515,7 +526,7 @@
|
|||||||
- swift-tox-py27:
|
- swift-tox-py27:
|
||||||
irrelevant-files: &unittest-irrelevant-files
|
irrelevant-files: &unittest-irrelevant-files
|
||||||
- ^(api-ref|doc|releasenotes)/.*$
|
- ^(api-ref|doc|releasenotes)/.*$
|
||||||
- ^test/(functional|probe)/.*$
|
- ^test/(cors|functional|probe)/.*$
|
||||||
- swift-tox-py36:
|
- swift-tox-py36:
|
||||||
irrelevant-files: *unittest-irrelevant-files
|
irrelevant-files: *unittest-irrelevant-files
|
||||||
- swift-tox-py37:
|
- swift-tox-py37:
|
||||||
@ -529,7 +540,7 @@
|
|||||||
- swift-tox-func-py27:
|
- swift-tox-func-py27:
|
||||||
irrelevant-files: &functest-irrelevant-files
|
irrelevant-files: &functest-irrelevant-files
|
||||||
- ^(api-ref|doc|releasenotes)/.*$
|
- ^(api-ref|doc|releasenotes)/.*$
|
||||||
- ^test/probe/.*$
|
- ^test/(cors|probe)/.*$
|
||||||
- ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$
|
- ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$
|
||||||
- swift-tox-func-encryption-py27:
|
- swift-tox-func-encryption-py27:
|
||||||
irrelevant-files: *functest-irrelevant-files
|
irrelevant-files: *functest-irrelevant-files
|
||||||
@ -545,20 +556,27 @@
|
|||||||
irrelevant-files: *functest-irrelevant-files
|
irrelevant-files: *functest-irrelevant-files
|
||||||
|
|
||||||
# Other tests
|
# 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:
|
- swift-tox-func-s3api-ceph-s3tests-tempauth:
|
||||||
irrelevant-files:
|
irrelevant-files:
|
||||||
- ^(api-ref|releasenotes)/.*$
|
- ^(api-ref|releasenotes)/.*$
|
||||||
# Keep doc/saio -- we use those sample configs in the saio playbooks
|
# Keep doc/saio -- we use those sample configs in the saio playbooks
|
||||||
# Also keep doc/s3api -- it holds known failures for these tests
|
# Also keep doc/s3api -- it holds known failures for these tests
|
||||||
- ^doc/(requirements.txt|(manpages|source)/.*)$
|
- ^doc/(requirements.txt|(manpages|source)/.*)$
|
||||||
- ^test/(unit|probe)/.*$
|
- ^test/(cors|unit|probe)/.*$
|
||||||
- ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$
|
- ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$
|
||||||
- swift-probetests-centos-7:
|
- swift-probetests-centos-7:
|
||||||
irrelevant-files: &probetest-irrelevant-files
|
irrelevant-files: &probetest-irrelevant-files
|
||||||
- ^(api-ref|releasenotes)/.*$
|
- ^(api-ref|releasenotes)/.*$
|
||||||
# Keep doc/saio -- we use those sample configs in the saio playbooks
|
# Keep doc/saio -- we use those sample configs in the saio playbooks
|
||||||
- ^doc/(requirements.txt|(manpages|s3api|source)/.*)$
|
- ^doc/(requirements.txt|(manpages|s3api|source)/.*)$
|
||||||
- ^test/(unit|functional)/.*$
|
- ^test/(cors|unit|functional)/.*$
|
||||||
- ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$
|
- ^(.gitreview|.mailmap|AUTHORS|CHANGELOG|.*\.rst)$
|
||||||
- swift-probetests-centos-8:
|
- swift-probetests-centos-8:
|
||||||
irrelevant-files: *probetest-irrelevant-files
|
irrelevant-files: *probetest-irrelevant-files
|
||||||
@ -606,6 +624,7 @@
|
|||||||
- swift-tox-func-py37
|
- swift-tox-func-py37
|
||||||
- swift-tox-func-encryption-py37
|
- swift-tox-func-encryption-py37
|
||||||
- swift-tox-func-ec-py37
|
- swift-tox-func-ec-py37
|
||||||
|
- swift-func-cors
|
||||||
- swift-probetests-centos-7:
|
- swift-probetests-centos-7:
|
||||||
irrelevant-files: *probetest-irrelevant-files
|
irrelevant-files: *probetest-irrelevant-files
|
||||||
- swift-probetests-centos-8:
|
- 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