-
ScienceMesh WOPI Server %s at %s. Powered by Flask %s for Python %s.
+
ScienceMesh WOPI Server %s at %s. Powered by Flask %s for Python %s.
+ Storage type: %s.
+ Health status: %s.
- """ % (WOPISERVERVERSION, socket.getfqdn(), flask.__version__, python_version()))
+ """ % (WOPISERVERVERSION, socket.getfqdn(), version('flask'), python_version(),
+ Wopi.config.get('general', 'storagetype'), storage.healthcheck()))
resp.headers['X-Frame-Options'] = 'sameorigin'
resp.headers['X-XSS-Protection'] = '1; mode=block'
return resp
@@ -251,6 +274,7 @@ def iopOpenInApp():
This can be omitted if the storage is based on CS3, as Reva would authenticate calls via the TokenHeader below.
- TokenHeader: an x-access-token to serve as user identity towards Reva
- ApiKey (optional): a shared secret to be used with the end-user application if required
+ - X-Trace-Id (optional): a trace id to cross-reference logs
Request arguments:
- enum viewmode: how the user should access the file, according to utils.ViewMode/the CS3 app provider API
- string fileid: the Reva fileid of the file to be opened
@@ -265,6 +289,8 @@ def iopOpenInApp():
- string appurl: the URL of the end-user application
- string appviewurl (optional): the URL of the end-user application in view mode when different (defaults to appurl)
- string appinturl (optional): the internal URL of the end-user application (applicable with containerized deployments)
+ - string usertype (optional): one of "regular", "federated", "ocm", "anonymous". Defaults to "regular"
+
Returns: a JSON response as follows:
{
"app-url" : "
",
@@ -283,13 +309,13 @@ def iopOpenInApp():
try:
usertoken = req.headers['TokenHeader']
except KeyError:
- Wopi.log.warning('msg="iopOpenInApp: missing TokenHeader in request" client="%s"' % req.remote_addr)
+ Wopi.log.warning(f'msg="iopOpenInApp: missing TokenHeader in request" client="{req.remote_addr}"')
return UNAUTHORIZED
# validate all parameters
fileid = req.args.get('fileid', '')
if not fileid:
- Wopi.log.warning('msg="iopOpenInApp: fileid must be provided" client="%s"' % req.remote_addr)
+ Wopi.log.warning(f'msg="iopOpenInApp: fileid must be provided" client="{req.remote_addr}"')
return 'Missing fileid argument', http.client.BAD_REQUEST
try:
viewmode = utils.ViewMode(req.args['viewmode'])
@@ -297,7 +323,7 @@ def iopOpenInApp():
Wopi.log.warning('msg="iopOpenInApp: invalid viewmode parameter" client="%s" viewmode="%s" error="%s"' %
(req.remote_addr, req.args.get('viewmode'), e))
return 'Missing or invalid viewmode argument', http.client.BAD_REQUEST
- username = req.args.get('username', '')
+ username = url_unquote_plus(req.args.get('username', ''))
# this needs to be a unique identifier: if missing (case of anonymous users), just generate a random string
wopiuser = req.args.get('userid', utils.randomString(10))
folderurl = url_unquote_plus(req.args.get('folderurl', '%2F')) # defaults to `/`
@@ -305,46 +331,50 @@ def iopOpenInApp():
appname = url_unquote_plus(req.args.get('appname', ''))
appurl = url_unquote_plus(req.args.get('appurl', '')).strip('/')
appviewurl = url_unquote_plus(req.args.get('appviewurl', appurl)).strip('/')
+ try:
+ usertype = utils.UserType(req.args.get('usertype', utils.UserType.REGULAR))
+ except (KeyError, ValueError) as e:
+ Wopi.log.warning('msg="iopOpenInApp: invalid usertype, falling back to regular" client="%s" usertype="%s" error="%s"' %
+ (req.remote_addr, req.args.get('usertype'), e))
+ usertype = utils.UserType.REGULAR
if not appname or not appurl:
- Wopi.log.warning('msg="iopOpenInApp: app-related arguments must be provided" client="%s"' % req.remote_addr)
+ Wopi.log.warning(f'msg="iopOpenInApp: app-related arguments must be provided" client="{req.remote_addr}"')
return 'Missing appname or appurl arguments', http.client.BAD_REQUEST
- if bridge.issupported(appname):
- # This is a bridge-supported application, get the extra info to enable it
- apikey = req.headers.get('ApiKey')
- appinturl = url_unquote_plus(req.args.get('appinturl', appurl)) # defaults to the external appurl
- try:
- bridge.WB.loadplugin(appname, appurl, appinturl, apikey)
- except ValueError:
- return 'Failed to load WOPI bridge plugin for %s' % appname, http.client.INTERNAL_SERVER_ERROR
-
try:
- userid = storage.getuseridfromcreds(usertoken, wopiuser)
- if userid != usertoken:
- # this happens in hybrid deployments with xrootd as storage interface:
- # in this case we override the wopiuser with the resolved uid:gid
- wopiuser = userid
- inode, acctok = utils.generateAccessToken(userid, fileid, viewmode, (username, wopiuser), folderurl, endpoint,
- (appname, appurl, appviewurl))
+ userid, wopiuser = storage.getuseridfromcreds(usertoken, wopiuser)
+ inode, acctok, vm = utils.generateAccessToken(userid, fileid, viewmode, (username, wopiuser, usertype), folderurl,
+ endpoint, (appname, appurl, appviewurl),
+ req.headers.get('X-Trace-Id', 'N/A'))
except IOError as e:
- Wopi.log.info('msg="iopOpenInApp: remote error on generating token" client="%s" user="%s" '
+ Wopi.log.info('msg="iopOpenInApp: remote error on generating token" client="%s" trace="%s" user="%s" '
'friendlyname="%s" mode="%s" endpoint="%s" reason="%s"' %
- (req.remote_addr, usertoken[-20:], username, viewmode, endpoint, e))
+ (req.remote_addr, req.headers.get('X-Trace-Id', 'N/A'), usertoken[-20:], username, viewmode, endpoint, e))
return 'Remote error, file not found or file is a directory', http.client.NOT_FOUND
res = {}
if bridge.issupported(appname):
try:
- res['app-url'], res['form-parameters'] = bridge.appopen(utils.generateWopiSrc(inode), acctok, appname)
+ res['app-url'], res['form-parameters'] = bridge.appopen(utils.generateWopiSrc(inode), acctok,
+ (appname, appurl, url_unquote_plus(req.args.get('appinturl', appurl)), req.headers.get('ApiKey')), # noqa: E128
+ vm, usertoken)
except bridge.FailedOpen as foe:
return foe.msg, foe.statuscode
else:
- res['app-url'] = appurl if viewmode == utils.ViewMode.READ_WRITE else appviewurl
+ # the base app URL is the editor in READ_WRITE mode, and the viewer in READ_ONLY or PREVIEW mode
+ # as the known WOPI applications all support switching from preview to edit mode
+ res['app-url'] = appurl if vm == utils.ViewMode.READ_WRITE else appviewurl
res['app-url'] += '%sWOPISrc=%s' % ('&' if '?' in res['app-url'] else '?',
utils.generateWopiSrc(inode, appname == Wopi.proxiedappname))
+ if Wopi.config.get('general', 'businessflow', fallback='False').upper() == 'TRUE':
+ # tells the app to enable the business flow if appropriate
+ res['app-url'] += '&IsLicensedUser=1'
res['form-parameters'] = {'access_token': acctok}
- Wopi.log.info('msg="iopOpenInApp: redirecting client" appurl="%s"' % res['app-url'])
+ appforlog = res['app-url']
+ if appforlog.find('access') > 0:
+ appforlog = appforlog[:appforlog.find('access')] + 'access_token=redacted'
+ Wopi.log.info(f"msg=\"iopOpenInApp: redirecting client\" appurl=\"{appforlog}\"")
return flask.Response(json.dumps(res), mimetype='application/json')
@@ -356,10 +386,14 @@ def iopDownload():
acctok = jwt.decode(flask.request.args['access_token'], Wopi.wopisecret, algorithms=['HS256'])
if acctok['exp'] < time.time():
raise jwt.exceptions.ExpiredSignatureError
+ Wopi.log.info('msg="iopDownload: returning contents" client="%s" endpoint="%s" filename="%s" token="%s"' %
+ (flask.request.remote_addr, acctok['endpoint'], acctok['filename'],
+ flask.request.args['access_token'][-20:]))
return core.wopi.getFile(0, acctok) # note that here we exploit the non-dependency from fileid
except (jwt.exceptions.DecodeError, jwt.exceptions.ExpiredSignatureError, KeyError) as e:
Wopi.log.info('msg="Expired or malformed token" client="%s" requestedUrl="%s" error="%s" token="%s"' %
- (flask.request.remote_addr, flask.request.base_url, e, flask.request.args['access_token']))
+ (flask.request.remote_addr, flask.request.base_url, e,
+ (flask.request.args['access_token'] if 'access_token' in flask.request.args else 'N/A')))
return 'Invalid access token', http.client.UNAUTHORIZED
@@ -377,10 +411,25 @@ def iopGetOpenFiles():
for f in list(Wopi.openfiles.keys()):
jlist[f] = (Wopi.openfiles[f][0], tuple(Wopi.openfiles[f][1]))
# dump the current list of opened files in JSON format
- Wopi.log.info('msg="iopGetOpenFiles: returning list of open files" client="%s"' % req.remote_addr)
+ Wopi.log.info(f'msg="iopGetOpenFiles: returning list of open files" client="{req.remote_addr}"')
return flask.Response(json.dumps(jlist), mimetype='application/json')
+@Wopi.app.route("/wopi/iop/conflicts", methods=['GET'])
+def iopGetConflicts():
+ '''Returns a list of all currently outstanding and resolved conflicted sessions, for operators only.
+ This call is protected by the same shared secret as the /wopi/iop/openinapp call.'''
+ req = flask.request
+ if req.headers.get('Authorization') != 'Bearer ' + Wopi.iopsecret:
+ Wopi.log.warning('msg="iopGetConflicts: unauthorized access attempt, missing authorization token" '
+ 'client="%s"' % req.remote_addr)
+ return UNAUTHORIZED
+ # dump the current sets in JSON format
+ Wopi.log.info(f'msg="iopGetConflicts: returning outstanding/resolved conflicted sessions" client="{req.remote_addr}"')
+ Wopi.conflictsessions['users'] = len(Wopi.allusers)
+ return flask.Response(json.dumps(Wopi.conflictsessions), mimetype='application/json')
+
+
@Wopi.app.route("/wopi/iop/test", methods=['GET'])
def iopWopiTest():
'''Returns a WOPI_URL and a WOPI_TOKEN values suitable as input for the WOPI validator test suite.
@@ -403,10 +452,10 @@ def iopWopiTest():
return 'Missing arguments', http.client.BAD_REQUEST
if Wopi.useHttps:
return 'WOPI validator not supported in https mode', http.client.BAD_REQUEST
- inode, acctok = utils.generateAccessToken(usertoken, filepath, utils.ViewMode.READ_WRITE, ('test', usertoken),
- 'http://folderurlfortestonly/', endpoint,
- ('WOPI validator', 'http://fortestonly/', 'http://fortestonly/'))
- Wopi.log.info('msg="iopWopiTest: preparing test via WOPI validator" client="%s"' % req.remote_addr)
+ inode, acctok, _ = utils.generateAccessToken(usertoken, filepath, utils.ViewMode.READ_WRITE, ('test', 'test!' + usertoken),
+ 'http://folderurlfortestonly/', endpoint,
+ ('WOPI validator', 'http://fortestonly/', 'http://fortestonly/'), 'TestTrace')
+ Wopi.log.info(f'msg="iopWopiTest: preparing test via WOPI validator" client="{req.remote_addr}"')
return '-e WOPI_URL=http://localhost:%d/wopi/files/%s -e WOPI_TOKEN=%s' % (Wopi.port, inode, acctok)
@@ -439,29 +488,31 @@ def wopiFilesPost(fileid):
op = headers['X-WOPI-Override'] # must be one of the following strings, throws KeyError if missing
except KeyError as e:
Wopi.log.warning('msg="Missing argument" client="%s" requestedUrl="%s" error="%s" token="%s"' %
- (flask.request.remote_addr, flask.request.base_url, e, flask.request.args.get('access_token')[-20:]))
+ (flask.request.headers.get(utils.REALIPHEADER, flask.request.remote_addr), flask.request.base_url,
+ e, flask.request.args.get('access_token')))
return 'Missing argument', http.client.BAD_REQUEST
acctokOrMsg, httpcode = utils.validateAndLogHeaders(op)
if httpcode:
return acctokOrMsg, httpcode
- if op != 'GET_LOCK' and utils.ViewMode(acctokOrMsg['viewmode']) != utils.ViewMode.READ_WRITE:
- # protect this call if the WOPI client does not have privileges
+ if op == 'GET_LOCK':
+ return core.wopi.getLock(fileid, headers, acctokOrMsg)
+ if op == 'PUT_USER_INFO':
+ return core.wopi.putUserInfo(fileid, flask.request.get_data(), acctokOrMsg)
+ if op == 'PUT_RELATIVE':
+ return core.wopi.putRelative(fileid, headers, acctokOrMsg)
+ if utils.ViewMode(acctokOrMsg['viewmode']) not in (utils.ViewMode.READ_WRITE, utils.ViewMode.PREVIEW):
+ # the remaining operations require write privileges
return 'Attempting to perform a write operation using a read-only token', http.client.UNAUTHORIZED
if op in ('LOCK', 'REFRESH_LOCK'):
return core.wopi.setLock(fileid, headers, acctokOrMsg)
- if op == 'GET_LOCK':
- return core.wopi.getLock(fileid, headers, acctokOrMsg)
if op == 'UNLOCK':
return core.wopi.unlock(fileid, headers, acctokOrMsg)
- if op == 'PUT_RELATIVE':
- return core.wopi.putRelative(fileid, headers, acctokOrMsg)
if op == 'DELETE':
return core.wopi.deleteFile(fileid, headers, acctokOrMsg)
if op == 'RENAME_FILE':
return core.wopi.renameFile(fileid, headers, acctokOrMsg)
- # elif op == 'PUT_USER_INFO':
# Any other op is unsupported
- Wopi.log.warning('msg="Unknown/unsupported operation" operation="%s"' % op)
+ Wopi.log.warning(f'msg="Unknown/unsupported operation" operation="{op}"')
return 'Not supported operation found in header', http.client.NOT_IMPLEMENTED
@@ -491,119 +542,8 @@ def bridgeList():
#
-# Deprecated cbox endpoints
-#
-@Wopi.app.route("/wopi/cbox/open", methods=['GET'])
-@Wopi.metrics.do_not_track()
-@Wopi.metrics.counter('open_by_ext', 'Number of /open calls by file extension',
- labels={'open_type': lambda:
- flask.request.args['filename'].split('.')[-1]
- if 'filename' in flask.request.args and '.' in flask.request.args['filename']
- else ('noext' if 'filename' in flask.request.args else 'fileid')
- })
-def cboxOpen_deprecated():
- '''Generates a WOPISrc target and an access token to be passed to a WOPI-compatible Office-like app
- for accessing a given file for a given user.
- Required headers:
- - Authorization: a bearer shared secret to protect this call as it provides direct access to any user's file
- Request arguments:
- - int ruid, rgid: a real Unix user identity (id:group) representing the user accessing the file
- - enum viewmode: how the user should access the file, according to utils.ViewMode/the CS3 app provider API
- - OR bool canedit: True if full access should be given to the user, otherwise read-only access is granted
- - string username (optional): user's full display name, typically shown by the Office app
- - string filename: the full path of the filename to be opened
- - string endpoint (optional): the storage endpoint to be used to look up the file or the storage id, in case of
- multi-instance underlying storage; defaults to 'default'
- - string folderurl (optional): the URL to come back to the containing folder for this file, typically shown by the Office app
- - boolean proxy (optional): whether the returned WOPISrc must be proxied or not, defaults to false
- Returns: a single string with the application URL, or a message and a 4xx/5xx HTTP code in case of errors
- '''
- Wopi.refreshconfig()
- req = flask.request
- # if running in https mode, first check if the shared secret matches ours
- if req.headers.get('Authorization') != 'Bearer ' + Wopi.iopsecret:
- Wopi.log.warning('msg="cboxOpen: unauthorized access attempt, missing authorization token" '
- 'client="%s" clientAuth="%s"' % (req.remote_addr, req.headers.get('Authorization')))
- return UNAUTHORIZED
- # now validate the user identity and deny root access
- try:
- userid = 'N/A'
- ruid = int(req.args['ruid'])
- rgid = int(req.args['rgid'])
- userid = '%d:%d' % (ruid, rgid)
- if ruid == 0 or rgid == 0:
- raise ValueError
- except ValueError:
- Wopi.log.warning('msg="cboxOpen: invalid or missing user/token in request" client="%s" user="%s"' %
- (req.remote_addr, userid))
- return UNAUTHORIZED
- filename = url_unquote_plus(req.args.get('filename', ''))
- if filename == '':
- Wopi.log.warning('msg="cboxOpen: the filename must be provided" client="%s"' % req.remote_addr)
- return 'Invalid argument', http.client.BAD_REQUEST
- if 'viewmode' in req.args:
- try:
- viewmode = utils.ViewMode(req.args['viewmode'])
- except ValueError:
- Wopi.log.warning('msg="cboxOpen: invalid viewmode parameter" client="%s" viewmode="%s"' %
- (req.remote_addr, req.args['viewmode']))
- return 'Invalid argument', http.client.BAD_REQUEST
- else:
- # backwards compatibility
- viewmode = utils.ViewMode.READ_WRITE if 'canedit' in req.args and req.args['canedit'].lower() == 'true' \
- else utils.ViewMode.READ_ONLY
- username = req.args.get('username', '')
- folderurl = url_unquote_plus(req.args.get('folderurl', '%2F')) # defaults to `/`
- endpoint = req.args.get('endpoint', 'default')
- toproxy = req.args.get('proxy', 'false') == 'true' and filename[-1] == 'x' # if requested, only proxy OOXML files
- try:
- # here we set wopiuser = userid (i.e. uid:gid) as that's well known to be consistent over time
- inode, acctok = utils.generateAccessToken(userid, filename, viewmode, (username, userid),
- folderurl, endpoint, (Wopi.proxiedappname if toproxy else '', '', ''))
- except IOError as e:
- Wopi.log.warning('msg="cboxOpen: remote error on generating token" client="%s" user="%s" '
- 'friendlyname="%s" mode="%s" endpoint="%s" reason="%s"' %
- (req.remote_addr, userid, username, viewmode, endpoint, e))
- return 'Remote error, file or app not found or file is a directory', http.client.NOT_FOUND
- if bridge.isextsupported(os.path.splitext(filename)[1][1:]):
- # call the bridgeOpen right away, to not expose the WOPI URL to the user (it might be behind firewall)
- try:
- appurl, _ = bridge.appopen(utils.generateWopiSrc(inode), acctok,
- bridge.BRIDGE_EXT_PLUGINS[os.path.splitext(filename)[1][1:]])
- Wopi.log.debug('msg="cboxOpen: returning bridged app" URL="%s"' % appurl[appurl.rfind('/'):])
- return appurl[appurl.rfind('/'):] # return the payload as the appurl is already known via discovery
- except bridge.FailedOpen as foe:
- Wopi.log.warning('msg="cboxOpen: open via bridge failed" reason="%s"' % foe.msg)
- return foe.msg, foe.statuscode
- # generate the target for the app engine
- wopisrc = '%s&access_token=%s' % (utils.generateWopiSrc(inode, toproxy), acctok)
- return wopisrc
-
-
-@Wopi.app.route("/wopi/cbox/endpoints", methods=['GET'])
-@Wopi.metrics.do_not_track()
-def cboxAppEndPoints_deprecated():
- '''Returns the office apps end-points registered with this WOPI server. This is used by the old Reva
- to discover which Apps frontends can be used with this WOPI server. The new Reva/IOP
- includes this logic in the AppProvider and AppRegistry, and once it's fully adopted this logic
- will be removed from the WOPI server.
- Note that if the end-points are relocated and the corresponding configuration entry updated,
- the WOPI server must be restarted.'''
- Wopi.log.info('msg="cboxEndPoints: returning all registered office apps end-points" client="%s" mimetypescount="%d"' %
- (flask.request.remote_addr, len(core.discovery.endpoints)))
- return flask.Response(json.dumps(core.discovery.endpoints), mimetype='application/json')
-
-
-@Wopi.app.route("/wopi/cbox/download", methods=['GET'])
-def cboxDownload_deprecated():
- '''The deprecated endpoint for download'''
- return iopDownload()
-
-
-#
-# Start the Flask endless listening loop
+# Start the app endless listening loop
#
if __name__ == '__main__':
Wopi.init()
- core.discovery.initappsregistry() # deprecated
Wopi.run()
diff --git a/test/test_storageiface.py b/test/test_storageiface.py
index b725d234..25290bc5 100644
--- a/test/test_storageiface.py
+++ b/test/test_storageiface.py
@@ -15,16 +15,19 @@
import os
import time
from threading import Thread
+from getpass import getpass
sys.path.append('src') # for tests out of the git repo
sys.path.append('/app') # for tests within the Docker image
from core.commoniface import EXCL_ERROR, ENOENT_MSG # noqa: E402
-databuf = b'ebe5tresbsrdthbrdhvdtr'
+databuf = 'ebe5tresbsrdthbrdhvdtr'
class TestStorage(unittest.TestCase):
'''Simple tests for the storage layers of the WOPI server. See README for how to run the tests for each storage provider'''
initialized = False
+ storagetype = None
+ log = None
@classmethod
def globalinit(cls):
@@ -32,9 +35,9 @@ def globalinit(cls):
loghandler = logging.FileHandler('/tmp/wopiserver-test.log')
loghandler.setFormatter(logging.Formatter(fmt='%(asctime)s %(name)s[%(process)d] %(levelname)-8s %(message)s',
datefmt='%Y-%m-%dT%H:%M:%S'))
- log = logging.getLogger('wopiserver.test')
- log.addHandler(loghandler)
- log.setLevel(logging.DEBUG)
+ cls.log = logging.getLogger('wopiserver.test')
+ cls.log.addHandler(loghandler)
+ cls.log.setLevel(logging.DEBUG)
config = configparser.ConfigParser()
try:
with open('test/wopiserver-test.conf') as fdconf:
@@ -44,6 +47,7 @@ def globalinit(cls):
storagetype = config.get('general', 'storagetype')
cls.userid = config.get(storagetype, 'userid')
cls.endpoint = config.get(storagetype, 'endpoint')
+ cls.storagetype = storagetype
except (KeyError, configparser.NoOptionError):
print("Missing option or missing configuration, check the wopiserver-test.conf file")
raise
@@ -52,26 +56,27 @@ def globalinit(cls):
if storagetype in ['local', 'xroot', 'cs3']:
storagetype += 'iface'
else:
- raise ImportError('Unsupported/Unknown storage type %s' % storagetype)
+ raise ImportError(f'Unsupported/Unknown storage type {storagetype}')
try:
cls.storage = __import__('core.' + storagetype, globals(), locals(), [storagetype])
- cls.storage.init(config, log)
+ cls.storage.init(config, cls.log)
cls.homepath = ''
cls.username = ''
if 'cs3' in storagetype:
# we need to login for this case
cls.username = cls.userid
- cls.userid = cls.storage.authenticate_for_test(cls.userid, config.get('cs3', 'userpwd'))
+ pwd = getpass(f"Please type {cls.username}'s password to access the storage: ")
+ cls.userid = cls.storage.authenticate_for_test(cls.username, pwd)
cls.homepath = config.get('cs3', 'storagehomepath')
except ImportError:
- print("Missing module when attempting to import %s. Please make sure dependencies are met." % storagetype)
+ print(f"Missing module when attempting to import {storagetype}. Please make sure dependencies are met.")
raise
- print('Global initialization succeded for storage interface %s, starting unit tests' % storagetype)
+ print(f'Global initialization succeded for storage interface {storagetype}, starting unit tests')
cls.initialized = True
def __init__(self, *args, **kwargs):
'''Initialization of a test'''
- super(TestStorage, self).__init__(*args, **kwargs)
+ super().__init__(*args, **kwargs)
if not TestStorage.initialized:
TestStorage.globalinit()
self.userid = TestStorage.userid
@@ -79,10 +84,11 @@ def __init__(self, *args, **kwargs):
self.storage = TestStorage.storage
self.homepath = TestStorage.homepath
self.username = TestStorage.username
+ self.log = TestStorage.log
def test_stat(self):
'''Call stat() and assert the path matches'''
- self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, -1, None)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/test.txt', self.userid)
self.assertIsInstance(statInfo, dict)
self.assertTrue('mtime' in statInfo, 'Missing mtime from stat output')
@@ -91,7 +97,7 @@ def test_stat(self):
def test_statx_fileid(self):
'''Call statx() and test if fileid-based stat is supported'''
- self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, -1, None)
statInfo = self.storage.statx(self.endpoint, self.homepath + '/test.txt', self.userid, versioninv=0)
self.assertTrue('inode' in statInfo, 'Missing inode from statx output')
self.assertTrue('filepath' in statInfo, 'Missing filepath from statx output')
@@ -99,20 +105,15 @@ def test_statx_fileid(self):
self.assertTrue('size' in statInfo, 'Missing size from stat output')
self.assertTrue('mtime' in statInfo, 'Missing mtime from stat output')
self.assertTrue('etag' in statInfo, 'Missing etag from stat output')
- if self.endpoint in str(statInfo['inode']):
- # detected CS3 storage, test if fileid-based stat is supported
- # (notably, homepath is not part of the fileid)
- statInfoId = self.storage.stat(self.endpoint, 'fileid-' + self.username + '%2Ftest.txt', self.userid)
- self.assertTrue(statInfo['inode'] == statInfoId['inode'])
self.storage.removefile(self.endpoint, self.homepath + '/test.txt', self.userid)
def test_statx_invariant_fileid(self):
'''Call statx() before and after updating a file, and assert the inode did not change'''
- self.storage.writefile(self.endpoint, self.homepath + '/test&upd.txt', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test&upd.txt', self.userid, databuf, -1, None)
statInfo = self.storage.statx(self.endpoint, self.homepath + '/test&upd.txt', self.userid)
self.assertIsInstance(statInfo, dict)
inode = statInfo['inode']
- self.storage.writefile(self.endpoint, self.homepath + '/test&upd.txt', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test&upd.txt', self.userid, databuf, -1, None)
statInfo = self.storage.statx(self.endpoint, self.homepath + '/test&upd.txt', self.userid)
self.assertIsInstance(statInfo, dict)
self.assertEqual(statInfo['inode'], inode, 'Fileid is not invariant to multiple write operations')
@@ -132,18 +133,18 @@ def test_statx_nofile(self):
def test_readfile_bin(self):
'''Writes a binary file and reads it back, validating that the content matches'''
- self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, -1, None)
content = ''
for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid, None):
self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile')
content += chunk.decode('utf-8')
- self.assertEqual(content, databuf.decode(), 'File test.txt should contain the string "%s"' % databuf.decode())
+ self.assertEqual(content, databuf, f'File test.txt should contain the string "{databuf}"')
self.storage.removefile(self.endpoint, self.homepath + '/test.txt', self.userid)
def test_readfile_text(self):
'''Writes a text file and reads it back, validating that the content matches'''
content = 'bla\n'
- self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, -1, None)
content = ''
for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid, None):
self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile')
@@ -154,7 +155,7 @@ def test_readfile_text(self):
def test_readfile_empty(self):
'''Writes an empty file and reads it back, validating that the read does not fail'''
content = ''
- self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, content, -1, None)
for chunk in self.storage.readfile(self.endpoint, self.homepath + '/test.txt', self.userid, None):
self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile')
content += chunk.decode('utf-8')
@@ -164,12 +165,12 @@ def test_readfile_empty(self):
def test_read_nofile(self):
'''Test reading of a non-existing file'''
readex = next(self.storage.readfile(self.endpoint, self.homepath + '/hopefullynotexisting', self.userid, None))
- self.assertIsInstance(readex, IOError, 'readfile returned %s' % readex)
- self.assertEqual(str(readex), ENOENT_MSG, 'readfile returned %s' % readex)
+ self.assertIsInstance(readex, IOError, f'readfile returned {readex}')
+ self.assertEqual(str(readex), ENOENT_MSG, f'readfile returned {readex}')
def test_write_remove_specialchars(self):
'''Test write and removal of a file with special chars'''
- self.storage.writefile(self.endpoint, self.homepath + '/testwrite&rm', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/testwrite&rm', self.userid, databuf, -1, None)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/testwrite&rm', self.userid)
self.assertIsInstance(statInfo, dict)
self.storage.removefile(self.endpoint, self.homepath + '/testwrite&rm', self.userid)
@@ -178,30 +179,37 @@ def test_write_remove_specialchars(self):
def test_write_islock(self):
'''Test double write with the islock flag'''
+ if self.storagetype == 'cs3':
+ self.log.warn('Skipping test_write_islock for storagetype cs3')
+ return
try:
self.storage.removefile(self.endpoint, self.homepath + '/testoverwrite', self.userid)
except IOError:
pass
- self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, databuf, None, islock=True)
+ self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, databuf, -1, None, islock=True)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/testoverwrite', self.userid)
self.assertIsInstance(statInfo, dict)
with self.assertRaises(IOError) as context:
- self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, databuf, None, islock=True)
+ self.storage.writefile(self.endpoint, self.homepath + '/testoverwrite', self.userid, databuf, -1, None, islock=True)
self.assertIn(EXCL_ERROR, str(context.exception))
self.storage.removefile(self.endpoint, self.homepath + '/testoverwrite', self.userid)
def test_write_race(self):
'''Test multithreaded double write with the islock flag. Might fail as it relies on tight timing'''
+ if self.storagetype == 'cs3':
+ self.log.warn('Skipping test_write_race for storagetype cs3')
+ return
try:
self.storage.removefile(self.endpoint, self.homepath + '/testwriterace', self.userid)
except IOError:
pass
t = Thread(target=self.storage.writefile,
- args=[self.endpoint, self.homepath + '/testwriterace', self.userid, databuf, None], kwargs={'islock': True})
+ args=[self.endpoint, self.homepath + '/testwriterace', self.userid, databuf, -1, None],
+ kwargs={'islock': True})
t.start()
with self.assertRaises(IOError) as context:
time.sleep(0.001)
- self.storage.writefile(self.endpoint, self.homepath + '/testwriterace', self.userid, databuf, None, islock=True)
+ self.storage.writefile(self.endpoint, self.homepath + '/testwriterace', self.userid, databuf, -1, None, islock=True)
self.assertIn(EXCL_ERROR, str(context.exception))
t.join()
self.storage.removefile(self.endpoint, self.homepath + '/testwriterace', self.userid)
@@ -212,20 +220,20 @@ def test_lock(self):
self.storage.removefile(self.endpoint, self.homepath + '/testlock', self.userid)
except IOError:
pass
- self.storage.writefile(self.endpoint, self.homepath + '/testlock', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/testlock', self.userid, databuf, -1, None)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlock', self.userid)
self.assertIsInstance(statInfo, dict)
- self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'myapp', 'testlock')
+ self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'test app', 'testlock')
l = self.storage.getlock(self.endpoint, self.homepath + '/testlock', self.userid) # noqa: E741
self.assertIsInstance(l, dict)
self.assertEqual(l['lock_id'], 'testlock')
- self.assertEqual(l['app_name'], 'myapp')
+ self.assertEqual(l['app_name'], 'test app')
self.assertIsInstance(l['expiration'], dict)
self.assertIsInstance(l['expiration']['seconds'], int)
with self.assertRaises(IOError) as context:
- self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'myapp', 'testlock2')
+ self.storage.setlock(self.endpoint, self.homepath + '/testlock', self.userid, 'mismatched app', 'mismatchlock')
self.assertIn(EXCL_ERROR, str(context.exception))
- self.storage.unlock(self.endpoint, self.homepath + '/testlock', self.userid, 'myapp', 'testlock')
+ self.storage.unlock(self.endpoint, self.homepath + '/testlock', self.userid, 'test app', 'testlock')
self.storage.removefile(self.endpoint, self.homepath + '/testlock', self.userid)
def test_refresh_lock(self):
@@ -234,23 +242,27 @@ def test_refresh_lock(self):
self.storage.removefile(self.endpoint, self.homepath + '/testrlock', self.userid)
except IOError:
pass
- self.storage.writefile(self.endpoint, self.homepath + '/testrlock', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/testrlock', self.userid, databuf, -1, None)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/testrlock', self.userid)
self.assertIsInstance(statInfo, dict)
with self.assertRaises(IOError) as context:
- self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp', 'testlock')
- self.assertIn('File was not locked', str(context.exception))
- self.storage.setlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp', 'testlock')
- self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp', 'testlock2')
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'test app', 'testlock')
+ self.assertEqual(EXCL_ERROR, str(context.exception))
+ self.storage.setlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'test app', 'testlock')
+ with self.assertRaises(IOError) as context:
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'test app', 'newlock', 'mismatch')
+ self.assertEqual(EXCL_ERROR, str(context.exception))
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'test app', 'newlock', 'testlock')
l = self.storage.getlock(self.endpoint, self.homepath + '/testrlock', self.userid) # noqa: E741
self.assertIsInstance(l, dict)
- self.assertEqual(l['lock_id'], 'testlock2')
- self.assertEqual(l['app_name'], 'myapp')
+ self.assertEqual(l['lock_id'], 'newlock')
+ self.assertEqual(l['app_name'], 'test app')
self.assertIsInstance(l['expiration'], dict)
self.assertIsInstance(l['expiration']['seconds'], int)
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'test app', 'newlock')
with self.assertRaises(IOError) as context:
- self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'myapp2', 'testlock2')
- self.assertIn('File is locked by myapp', str(context.exception))
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testrlock', self.userid, 'mismatched app', 'newlock')
+ self.assertIn(EXCL_ERROR, str(context.exception))
self.storage.removefile(self.endpoint, self.homepath + '/testrlock', self.userid)
def test_lock_race(self):
@@ -259,15 +271,15 @@ def test_lock_race(self):
self.storage.removefile(self.endpoint, self.homepath + '/testlockrace', self.userid)
except IOError:
pass
- self.storage.writefile(self.endpoint, self.homepath + '/testlockrace', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/testlockrace', self.userid, databuf, -1, None)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlockrace', self.userid)
self.assertIsInstance(statInfo, dict)
t = Thread(target=self.storage.setlock,
- args=[self.endpoint, self.homepath + '/testlockrace', self.userid, 'myapp', 'testlock'])
+ args=[self.endpoint, self.homepath + '/testlockrace', self.userid, 'test app', 'testlock'])
t.start()
with self.assertRaises(IOError) as context:
time.sleep(0.001)
- self.storage.setlock(self.endpoint, self.homepath + '/testlockrace', self.userid, 'myapp', 'testlock2')
+ self.storage.setlock(self.endpoint, self.homepath + '/testlockrace', self.userid, 'test app 2', 'testlock2')
self.assertIn(EXCL_ERROR, str(context.exception))
self.storage.removefile(self.endpoint, self.homepath + '/testlockrace', self.userid)
@@ -277,22 +289,44 @@ def test_lock_operations(self):
self.storage.removefile(self.endpoint, self.homepath + '/testlockop', self.userid)
except IOError:
pass
- self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, -1, None)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/testlockop', self.userid)
self.assertIsInstance(statInfo, dict)
- self.storage.setlock(self.endpoint, self.homepath + '/testlockop', self.userid, 'myapp', 'testlock')
- self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, 'testlock')
- self.storage.setxattr(self.endpoint, self.homepath + '/testlockop', self.userid, 'testkey', 123, 'testlock')
- self.storage.renamefile(self.endpoint, self.homepath + '/testlockop', self.homepath + '/testlockop_renamed',
- self.userid, 'testlock')
- self.storage.refreshlock(self.endpoint, self.homepath + '/testlockop_renamed', self.userid, 'myapp', 'testlock')
+ self.storage.setlock(self.endpoint, self.homepath + '/testlockop', self.userid, 'test app', 'testlock')
+ self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, -1, ('test app', 'testlock'))
with self.assertRaises(IOError):
- self.storage.writefile(self.endpoint, self.homepath + '/testlockop_renamed', self.userid, databuf, None)
+ # Note that different interfaces raise exceptions on either mismatching app xor mismatching lock payload,
+ # this is why we test that both mismatch. Could be improved, though we specifically care about the lock paylaod.
+ self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, -1,
+ ('mismatch app', 'mismatchlock'))
+ # with xattrs, it's fine to set them without lock context (the lock should apply to files' contents only)
+ # BUT the CS3 API does have the possibility to fail a setxattr in case of lock mismatch, so we allow that
+ # (cf. https://buf.build/cs3org-buf/cs3apis/docs/main:cs3.storage.provider.v1beta1#cs3.storage.provider.v1beta1.ProviderAPI.SetArbitraryMetadata) # noqa: E501
+ try:
+ self.storage.setxattr(self.endpoint, self.homepath + '/testlockop', self.userid, 'testkey', 123,
+ ('mismatch app', 'mismatchlock'))
+ except IOError as e:
+ if str(e) == EXCL_ERROR:
+ pass
+ try:
+ self.storage.setxattr(self.endpoint, self.homepath + '/testlockop', self.userid, 'testkey', 123, None)
+ except IOError as e:
+ if str(e) == EXCL_ERROR:
+ pass
+ try:
+ self.storage.rmxattr(self.endpoint, self.homepath + '/testlockop', self.userid, 'testkey', None)
+ except IOError as e:
+ if str(e) == EXCL_ERROR:
+ pass
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testlockop', self.userid, 'test app', 'testlock')
with self.assertRaises(IOError):
- self.storage.setxattr(self.endpoint, self.homepath + '/testlockop_renamed', self.userid, 'testkey', 123, None)
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testlockop', self.userid, 'mismatched app', 'mismatchlock')
+ for chunk in self.storage.readfile(self.endpoint, self.homepath + '/testlockop', self.userid, None):
+ self.assertNotIsInstance(chunk, IOError, 'raised by storage.readfile, lock shall be shared')
with self.assertRaises(IOError):
- self.storage.renamefile(self.endpoint, self.homepath + '/testlockop_renamed', self.homepath + '/testlockop',
- self.userid, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/testlockop', self.userid, databuf, -1, None)
+ self.storage.renamefile(self.endpoint, self.homepath + '/testlockop', self.homepath + '/testlockop_renamed',
+ self.userid, ('test app', 'testlock'))
self.storage.removefile(self.endpoint, self.homepath + '/testlockop_renamed', self.userid)
def test_expired_locks(self):
@@ -301,28 +335,28 @@ def test_expired_locks(self):
self.storage.removefile(self.endpoint, self.homepath + '/testelock', self.userid)
except IOError:
pass
- self.storage.writefile(self.endpoint, self.homepath + '/testelock', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/testelock', self.userid, databuf, -1, None)
statInfo = self.storage.stat(self.endpoint, self.homepath + '/testelock', self.userid)
self.assertIsInstance(statInfo, dict)
- self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'myapp', 'testlock')
- time.sleep(2.1)
+ self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'test app', 'testlock')
+ time.sleep(3.1)
l = self.storage.getlock(self.endpoint, self.homepath + '/testelock', self.userid) # noqa: E741
self.assertEqual(l, None)
- self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'myapp', 'testlock2')
- time.sleep(2.1)
- self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'myapp', 'testlock3')
+ self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'test app', 'testlock2')
+ time.sleep(3.1)
+ self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'test app', 'testlock3')
l = self.storage.getlock(self.endpoint, self.homepath + '/testelock', self.userid) # noqa: E741
self.assertIsInstance(l, dict)
self.assertEqual(l['lock_id'], 'testlock3')
- time.sleep(2.1)
+ time.sleep(3.1)
with self.assertRaises(IOError) as context:
- self.storage.refreshlock(self.endpoint, self.homepath + '/testelock', self.userid, 'myapp', 'testlock4')
- self.assertIn('File was not locked', str(context.exception))
- self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'myapp', 'testlock5')
- time.sleep(2.1)
+ self.storage.refreshlock(self.endpoint, self.homepath + '/testelock', self.userid, 'test app', 'testlock4')
+ self.assertEqual(EXCL_ERROR, str(context.exception))
+ self.storage.setlock(self.endpoint, self.homepath + '/testelock', self.userid, 'test app', 'testlock5')
+ time.sleep(3.1)
with self.assertRaises(IOError) as context:
- self.storage.unlock(self.endpoint, self.homepath + '/testelock', self.userid, 'myapp', 'testlock5')
- self.assertIn('File was not locked', str(context.exception))
+ self.storage.unlock(self.endpoint, self.homepath + '/testelock', self.userid, 'test app', 'testlock5')
+ self.assertEqual(EXCL_ERROR, str(context.exception))
self.storage.removefile(self.endpoint, self.homepath + '/testelock', self.userid)
def test_remove_nofile(self):
@@ -333,24 +367,30 @@ def test_remove_nofile(self):
def test_xattr(self):
'''Test all xattr methods with special chars'''
- self.storage.writefile(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, databuf, -1, None)
self.storage.setxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey', 123, None)
+ self.storage.setlock(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'test app', 'xattrlock')
+ self.storage.setxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey', 123,
+ ('test app', 'xattrlock'))
v = self.storage.getxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey')
self.assertEqual(v, '123')
- self.storage.rmxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey', None)
+ self.storage.rmxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey', ('test app', 'xattrlock'))
v = self.storage.getxattr(self.endpoint, self.homepath + '/test&xattr.txt', self.userid, 'testkey')
self.assertEqual(v, None)
self.storage.removefile(self.endpoint, self.homepath + '/test&xattr.txt', self.userid)
def test_rename_statx(self):
'''Test renaming and statx of a file with special chars'''
- self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, None)
+ self.storage.writefile(self.endpoint, self.homepath + '/test.txt', self.userid, databuf, -1, None)
+ statInfo = self.storage.statx(self.endpoint, self.homepath + '/test.txt', self.userid)
+ pathref = statInfo['filepath'][:statInfo['filepath'].rfind('/')]
+
self.storage.renamefile(self.endpoint, self.homepath + '/test.txt', self.homepath + '/test&ren.txt', self.userid, None)
statInfo = self.storage.statx(self.endpoint, self.homepath + '/test&ren.txt', self.userid)
- self.assertEqual(statInfo['filepath'], self.homepath + '/test&ren.txt')
+ self.assertEqual(statInfo['filepath'], pathref + '/test&ren.txt')
self.storage.renamefile(self.endpoint, self.homepath + '/test&ren.txt', self.homepath + '/test.txt', self.userid, None)
statInfo = self.storage.statx(self.endpoint, self.homepath + '/test.txt', self.userid)
- self.assertEqual(statInfo['filepath'], self.homepath + '/test.txt')
+ self.assertEqual(statInfo['filepath'], pathref + '/test.txt')
self.storage.removefile(self.endpoint, self.homepath + '/test.txt', self.userid)
diff --git a/test/wopi-validator.md b/test/wopi-validator.md
index 686418fd..d6153615 100644
--- a/test/wopi-validator.md
+++ b/test/wopi-validator.md
@@ -4,7 +4,7 @@ These notes have been adaped from the enterprise ownCloud WOPI implementation, c
1. Setup your WOPI server as well as Reva as required. Make sure the WOPI storage interface unit tests pass.
-2. Create an empty folder and touch an file named `test.wopitest` in that folder. For a local Reva setup:
+2. Create an empty folder and touch a file named `test.wopitest` in that folder. For a local Reva setup:
`mkdir /var/tmp/reva/data/einstein/wopivalidator && touch /var/tmp/reva/data/einstein/wopivalidator/test.wopitest`.
@@ -14,6 +14,8 @@ These notes have been adaped from the enterprise ownCloud WOPI implementation, c
`curl -H "Authorization: Bearer " "http://your_wopi_server:port/wopi/iop/test?filepath=&endpoint=&usertoken="`
-5. Run the testsuite (you can select a specific test group passing as well e.g. `-e WOPI_TESTGROUP=FileVersion`):
+5. Run the testsuite:
`docker run --rm --add-host="localhost:"