diff --git a/.vscode-template/settings.json b/.vscode-template/settings.json index 144c74e0fde..0a606b22a86 100644 --- a/.vscode-template/settings.json +++ b/.vscode-template/settings.json @@ -15,7 +15,7 @@ "files.trimFinalNewlines": true, "files.trimTrailingWhitespace": true, "python.formatting.autopep8Args": [ - "-max-line-length", "140" + "--max-line-length 140" ], "python.linting.pylintEnabled": true, "python.linting.enabled": true, @@ -26,5 +26,6 @@ "[makefile]": { "editor.insertSpaces": false }, + "python.testing.pyTestEnabled": true, "autoDocstring.docstringFormat": "sphinx" } diff --git a/scripts/openapi/oas_resolver/Dockerfile b/scripts/openapi/oas_resolver/Dockerfile index 4fa115e0b42..d48a38d23e4 100644 --- a/scripts/openapi/oas_resolver/Dockerfile +++ b/scripts/openapi/oas_resolver/Dockerfile @@ -12,7 +12,7 @@ WORKDIR /src # update pip RUN pip install --no-cache-dir --upgrade \ - pip \ + pip~=19.1.1 \ wheel \ setuptools diff --git a/services/director/Dockerfile b/services/director/Dockerfile index 928f6f19d3d..884d46d0a82 100644 --- a/services/director/Dockerfile +++ b/services/director/Dockerfile @@ -48,7 +48,7 @@ RUN apk add --no-cache \ git RUN $SC_PIP install --upgrade \ - pip \ + pip~=19.1.1 \ wheel \ setuptools diff --git a/services/dy-3dvis/simcoreparaviewweb/Dockerfile b/services/dy-3dvis/simcoreparaviewweb/Dockerfile index c9855ff5551..c109e6ff830 100644 --- a/services/dy-3dvis/simcoreparaviewweb/Dockerfile +++ b/services/dy-3dvis/simcoreparaviewweb/Dockerfile @@ -47,7 +47,7 @@ COPY scripts/docker/healthcheck_curl_host.py /healthcheck/healthcheck_curl_host. # set up oSparc env variables ENV SIMCORE_NODE_UUID="-1" \ SIMCORE_USER_ID="-1" \ - STORAGE_ENDPOINT="=1" \ + STORAGE_ENDPOINT="=1" \ S3_ENDPOINT="=1" \ S3_ACCESS_KEY="-1" \ S3_SECRET_KEY="-1" \ @@ -101,4 +101,4 @@ RUN export PATH="${PYENV_ROOT}/bin:$PATH" && \ pip install /home/root/services/storage/client-sdk/python # copy script to get the inputs inside the local file system COPY services/dy-3dvis/simcoreparaviewweb/cgi_scripts /home/root/cgi_scripts -ENTRYPOINT [ "/bin/bash", "docker/boot.sh" ] \ No newline at end of file +ENTRYPOINT [ "/bin/bash", "docker/boot.sh" ] diff --git a/services/dy-modeling/server/Dockerfile b/services/dy-modeling/server/Dockerfile index 405839c4628..5483cbd7eef 100644 --- a/services/dy-modeling/server/Dockerfile +++ b/services/dy-modeling/server/Dockerfile @@ -90,4 +90,4 @@ COPY services/dy-modeling/server/source /home/node/source COPY services/dy-modeling/server/docker /home/node/docker RUN npm install -y -ENTRYPOINT [ "/bin/bash", "/home/node/docker/boot.sh" ] \ No newline at end of file +ENTRYPOINT [ "/bin/bash", "/home/node/docker/boot.sh" ] diff --git a/services/sidecar/Dockerfile b/services/sidecar/Dockerfile index e7134a84d93..a9045f6b862 100644 --- a/services/sidecar/Dockerfile +++ b/services/sidecar/Dockerfile @@ -44,7 +44,7 @@ RUN apk add --no-cache \ libc-dev RUN $SC_PIP install --upgrade \ - pip \ + pip~=19.1.1 \ wheel \ setuptools diff --git a/services/sidecar/src/sidecar/core.py b/services/sidecar/src/sidecar/core.py index 84dd195879d..f0febd3ad30 100644 --- a/services/sidecar/src/sidecar/core.py +++ b/services/sidecar/src/sidecar/core.py @@ -370,7 +370,7 @@ def postprocess(self): def inspect(self, celery_task, user_id, project_id, node_id): log.debug("ENTERING inspect pipeline:node %s: %s", project_id, node_id) - # import pdb; pdb.set_trace() + next_task_nodes = [] do_run = False diff --git a/services/storage/Dockerfile b/services/storage/Dockerfile index fa894e7d00d..abde5f04a27 100644 --- a/services/storage/Dockerfile +++ b/services/storage/Dockerfile @@ -44,7 +44,7 @@ RUN apk add --no-cache \ linux-headers RUN $SC_PIP install --upgrade \ - pip \ + pip~=19.1.1 \ wheel \ setuptools diff --git a/services/storage/tests/test_dsm.py b/services/storage/tests/test_dsm.py index c768f753104..f998f81aa78 100644 --- a/services/storage/tests/test_dsm.py +++ b/services/storage/tests/test_dsm.py @@ -9,7 +9,6 @@ import io import json import os -import pdb import urllib import uuid from pathlib import Path diff --git a/services/web/Dockerfile b/services/web/Dockerfile index 02eb9c9bb41..f2a9a88f4a4 100644 --- a/services/web/Dockerfile +++ b/services/web/Dockerfile @@ -49,7 +49,7 @@ RUN apk add --no-cache \ libffi-dev RUN $SC_PIP install --upgrade \ - pip \ + pip~=19.1.1 \ wheel \ setuptools diff --git a/services/web/client/.eslintrc.json b/services/web/client/.eslintrc.json index 7794d558575..5210a7ee3ca 100644 --- a/services/web/client/.eslintrc.json +++ b/services/web/client/.eslintrc.json @@ -24,5 +24,8 @@ } ], "no-warning-comments": "off" + }, + "env": { + "browser": true } } diff --git a/services/web/client/compile.json b/services/web/client/compile.json index 403e37681b7..8460d597e9d 100644 --- a/services/web/client/compile.json +++ b/services/web/client/compile.json @@ -2,16 +2,16 @@ "targets": [ { "type": "source", - "outputPath": "source-output", + "outputPath": "source-output" + }, + { + "type": "build", + "outputPath": "build-output", "bundle": { "include": [ "qx.*" ] } - }, - { - "type": "build", - "outputPath": "build-output" } ], "defaultTarget": "source", diff --git a/services/web/client/package-lock.json b/services/web/client/package-lock.json index ba8a110f7ed..19f977179ab 100644 --- a/services/web/client/package-lock.json +++ b/services/web/client/package-lock.json @@ -1148,7 +1148,6 @@ "glob": "^7.1.3", "image-size": "^0.6.3", "inquirer": "^6.2.0", - "json-to-ast": "git+https://github.com/johnspackman/json-to-ast.git#a42fbe89a4f2033062213c49bfec7cf429d94db6", "jsonlint": "^1.6.2", "node-fetch": "^1.7.3", "node-sass": "^4.10.0", @@ -3953,8 +3952,14 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/es6-promisify-all/-/es6-promisify-all-0.1.0.tgz", "integrity": "sha1-RCuaHqjgCw/VsodyVVBY/eXp8gc=", - "requires": { - "es6-promisify": "github:pgaubatz/es6-promisify#3d8966c58bace65f762b7335e99e3f43b987bce4" + "dependencies": { + "es6-promisify": { + "version": "github:pgaubatz/es6-promisify#3d8966c58bace65f762b7335e99e3f43b987bce4", + "from": "github:pgaubatz/es6-promisify#3d8966c58bace65f762b7335e99e3f43b987bce4", + "requires": { + "es6-promise": "^2.3.0" + } + } } }, "es6-set": { @@ -5009,11 +5014,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5026,15 +5033,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -5137,7 +5147,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -5147,6 +5158,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5159,17 +5171,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -5186,6 +5201,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -5258,7 +5274,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -5268,6 +5285,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -5373,6 +5391,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6394,10 +6413,6 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, - "json-to-ast": { - "version": "git+https://github.com/johnspackman/json-to-ast.git#a42fbe89a4f2033062213c49bfec7cf429d94db6", - "from": "git+https://github.com/johnspackman/json-to-ast.git#editable-json" - }, "jsonfile": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", diff --git a/services/web/client/source/class/qxapp/Application.js b/services/web/client/source/class/qxapp/Application.js index 9237f731c61..45b000a68df 100644 --- a/services/web/client/source/class/qxapp/Application.js +++ b/services/web/client/source/class/qxapp/Application.js @@ -69,7 +69,19 @@ qx.Class.define("qxapp.Application", { this.__restart(); }, this); - this.__restart(); + this.__initRouting(); + }, + + __initRouting: function() { + // Route: /#/study/{id} + // TODO: PC -> IP consider regex for uuid, i.e. /[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}/ ??? + let result = /#\/study\/([0-9a-zA-Z\-]+)/.exec(window.location.hash); + if (result) { + qxapp.utils.Utils.cookie.deleteCookie("user"); + qxapp.auth.Manager.getInstance().validateToken(() => this.__loadMainPage(result[1]), this.__loadLoginPage, this); + } else { + this.__restart(); + } }, __restart: function() { @@ -80,35 +92,37 @@ qx.Class.define("qxapp.Application", { isLogged = true; } - let view = null; - let options = null; - if (isLogged) { - this.__connectWebSocket(); - view = new qxapp.desktop.LayoutManager(); - options = { - top: 0, - bottom: 0, - left: 0, - right: 0 - }; - this.__loadView(view, options); + this.__loadMainPage(); } else { - this.__disconnectWebSocket(); - view = new qxapp.auth.MainView(); - view.addListener("done", function(msg) { - this.__restart(); - }, this); - options = { - top: "10%", - bottom: 0, - left: 0, - right: 0 - }; - this.__loadView(view, options); + qxapp.auth.Manager.getInstance().validateToken(this.__loadMainPage, this.__loadLoginPage, this); } }, + __loadLoginPage: function() { + this.__disconnectWebSocket(); + const view = new qxapp.auth.LoginPage(); + view.addListener("done", function(msg) { + this.__restart(); + }, this); + this.__loadView(view, { + top: "10%", + bottom: 0, + left: 0, + right: 0 + }); + }, + + __loadMainPage: function(studyId) { + this.__connectWebSocket(); + this.__loadView(new qxapp.desktop.MainPage(studyId), { + top: 0, + bottom: 0, + left: 0, + right: 0 + }); + }, + __loadView: function(view, options) { this.assert(view!==null); // Update root document and currentness diff --git a/services/web/client/source/class/qxapp/auth/Data.js b/services/web/client/source/class/qxapp/auth/Data.js index 7fd452e5a89..c8b8354b24a 100644 --- a/services/web/client/source/class/qxapp/auth/Data.js +++ b/services/web/client/source/class/qxapp/auth/Data.js @@ -31,7 +31,7 @@ qx.Class.define("qxapp.auth.Data", { auth: { init: null, nullable: true, - check: "qx.io.request.authentication.Basic" + check: "qxapp.io.request.authentication.Token" }, /** @@ -48,14 +48,20 @@ qx.Class.define("qxapp.auth.Data", { setToken: function(token) { if (token) { - this.setAuth(new qx.io.request.authentication.Basic(token, null)); + qxapp.utils.Utils.cookie.setCookie("user", token); + this.setAuth(new qxapp.io.request.authentication.Token(token)); } }, resetToken: function() { + qxapp.utils.Utils.cookie.setCookie("user", "logout"); this.resetAuth(); }, + isLogout: function() { + return qxapp.utils.Utils.cookie.getCookie("user") === "logout"; + }, + getUserName: function() { const email = qxapp.auth.Data.getInstance().getEmail(); if (email) { diff --git a/services/web/client/source/class/qxapp/auth/MainView.js b/services/web/client/source/class/qxapp/auth/LoginPage.js similarity index 88% rename from services/web/client/source/class/qxapp/auth/MainView.js rename to services/web/client/source/class/qxapp/auth/LoginPage.js index b6d9b821714..8870ccd4635 100644 --- a/services/web/client/source/class/qxapp/auth/MainView.js +++ b/services/web/client/source/class/qxapp/auth/LoginPage.js @@ -16,12 +16,11 @@ ************************************************************************ */ /** - * Main Authentication View: + * Main Authentication Page: * A multi-page view that fills all page - * -*/ + */ -qx.Class.define("qxapp.auth.MainView", { +qx.Class.define("qxapp.auth.LoginPage", { extend : qx.ui.core.Widget, /* @@ -45,10 +44,10 @@ qx.Class.define("qxapp.auth.MainView", { alignX: "center" }); - let login = new qxapp.auth.ui.LoginPage(); - let register = new qxapp.auth.ui.RegistrationPage(); - let resetRequest = new qxapp.auth.ui.ResetPassRequestPage(); - let reset = new qxapp.auth.ui.ResetPassPage(); + let login = new qxapp.auth.ui.LoginView(); + let register = new qxapp.auth.ui.RegistrationView(); + let resetRequest = new qxapp.auth.ui.ResetPassRequestView(); + let reset = new qxapp.auth.ui.ResetPassView(); pages.add(login); pages.add(register); diff --git a/services/web/client/source/class/qxapp/auth/Manager.js b/services/web/client/source/class/qxapp/auth/Manager.js index ed771d2a6cf..da4a55ea906 100644 --- a/services/web/client/source/class/qxapp/auth/Manager.js +++ b/services/web/client/source/class/qxapp/auth/Manager.js @@ -50,18 +50,44 @@ qx.Class.define("qxapp.auth.Manager", { // TODO: check if expired?? // TODO: request server if token is still valid (e.g. expired, etc) const auth = qxapp.auth.Data.getInstance().getAuth(); - return auth !== null && auth instanceof qx.io.request.authentication.Basic; + return auth !== null && auth instanceof qxapp.io.request.authentication.Token; }, - login: function(email, pass, successCbk, failCbk, context) { - // TODO: consider qx.promise instead of having two callbacks and a context might be nicer to work with + /** + * Function that checks if there is a token and validates it aginst the server. It executes a callback depending on the result. + * + * @param {Function} successCb Callback function to be called if the token validation succeeds. + * @param {Function} errorCb Callback function to be called if the token validation fails or some other error occurs. + * @param {Object} ctx Context that will be used inside the callback functions (this). + */ + validateToken: function(successCb, errorCb, ctx) { + if (qxapp.auth.Data.getInstance().isLogout()) { + errorCb.call(ctx); + } else { + const request = new qxapp.io.request.ApiRequest("/me", "GET"); + request.addListener("success", e => { + if (e.getTarget().getResponse().error) { + errorCb.call(ctx); + } else { + this.__loginUser(e.getTarget().getResponse().data.login); + successCb.call(ctx); + } + }); + request.addListener("statusError", e => { + errorCb.call(ctx); + }); + request.send(); + } + }, + + login: function(email, password, successCbk, failCbk, context) { + // TODO: consider qx.promise instead of having two callbacks an d a context might be nicer to work with let request = new qxapp.io.request.ApiRequest("/auth/login", "POST"); request.set({ - authentication: new qx.io.request.authentication.Basic(email, pass), requestData: { - "email": email, - "password": pass + email, + password } }); @@ -72,7 +98,7 @@ qx.Class.define("qxapp.auth.Manager", { // TODO: validate data against specs??? // TODO: activate tokens!? - this.__loginUser(email, data.token || "fake token"); + this.__loginUser(email); successCbk.call(context, data); }, this); @@ -82,6 +108,8 @@ qx.Class.define("qxapp.auth.Manager", { }, logout: function() { + const request = new qxapp.io.request.ApiRequest("/auth/logout", "GET"); + request.send(); this.__logoutUser(); this.fireEvent("logout"); }, @@ -141,14 +169,14 @@ qx.Class.define("qxapp.auth.Manager", { request.send(); }, - __loginUser: function(email, token) { - qxapp.auth.Data.getInstance().setToken(token); + __loginUser: function(email) { qxapp.auth.Data.getInstance().setEmail(email); + qxapp.auth.Data.getInstance().setToken(email); }, __logoutUser: function() { - qxapp.auth.Data.getInstance().resetToken(); qxapp.auth.Data.getInstance().resetEmail(); + qxapp.auth.Data.getInstance().resetToken(); }, __bindDefaultSuccessCallback: function(request, successCbk, context) { diff --git a/services/web/client/source/class/qxapp/auth/core/MAuth.js b/services/web/client/source/class/qxapp/auth/core/MAuth.js index 00ad4a884ed..df81e17aafb 100644 --- a/services/web/client/source/class/qxapp/auth/core/MAuth.js +++ b/services/web/client/source/class/qxapp/auth/core/MAuth.js @@ -27,23 +27,13 @@ qx.Mixin.define("qxapp.auth.core.MAuth", { * TODO: create its own widget under qxapp.core.ui.LinkButton (extend Button with different apperance) */ createLinkButton: function(txt, cbk, ctx) { - txt = "
- * let layoutManager = new qxapp.desktop.LayoutManager(); + * let layoutManager = new qxapp.desktop.MainPage(); * this.getRoot().add(layoutManager); **/ -qx.Class.define("qxapp.desktop.LayoutManager", { +qx.Class.define("qxapp.desktop.MainPage", { extend: qx.ui.core.Widget, - construct: function() { + construct: function(studyId) { this.base(); this._setLayout(new qx.ui.layout.VBox()); @@ -44,7 +44,7 @@ qx.Class.define("qxapp.desktop.LayoutManager", { let navBar = this.__navBar = this.__createNavigationBar(); this._add(navBar); - let prjStack = this.__prjStack = this.__createMainView(); + let prjStack = this.__prjStack = this.__createMainView(studyId); this._add(prjStack, { flex: 1 }); @@ -84,10 +84,10 @@ qx.Class.define("qxapp.desktop.LayoutManager", { return navBar; }, - __createMainView: function() { + __createMainView: function(studyId) { let prjStack = new qx.ui.container.Stack(); - let dashboard = this.__dashboard = new qxapp.desktop.Dashboard(); + let dashboard = this.__dashboard = new qxapp.desktop.Dashboard(studyId); dashboard.getStudyBrowser().addListener("startStudy", e => { const studyEditor = e.getData(); this.__showStudyEditor(studyEditor); diff --git a/services/web/client/source/class/qxapp/desktop/NavigationBar.js b/services/web/client/source/class/qxapp/desktop/NavigationBar.js index a100c9327eb..e72ce0c3896 100644 --- a/services/web/client/source/class/qxapp/desktop/NavigationBar.js +++ b/services/web/client/source/class/qxapp/desktop/NavigationBar.js @@ -194,8 +194,7 @@ qx.Class.define("qxapp.desktop.NavigationBar", { const logout = new qx.ui.menu.Button(this.tr("Logout")); logout.addListener("execute", e => { - const app = qx.core.Init.getApplication(); - app.logout(); + qx.core.Init.getApplication().logout(); }); menu.add(preferences); diff --git a/services/web/client/source/class/qxapp/desktop/StudyBrowser.js b/services/web/client/source/class/qxapp/desktop/StudyBrowser.js index fced7edf039..884a43b240a 100644 --- a/services/web/client/source/class/qxapp/desktop/StudyBrowser.js +++ b/services/web/client/source/class/qxapp/desktop/StudyBrowser.js @@ -38,7 +38,7 @@ qx.Class.define("qxapp.desktop.StudyBrowser", { extend: qx.ui.core.Widget, - construct: function() { + construct: function(studyId) { this.base(arguments); this.__studyResources = qxapp.io.rest.ResourceFactory.getInstance().createStudyResources(); @@ -63,6 +63,9 @@ qx.Class.define("qxapp.desktop.StudyBrowser", { iframe.dispose(); this.__createStudiesLayout(); this.__createCommandEvents(); + if (studyId) { + this.__createStudy(studyId); + } } }, this); userTimer.start(); @@ -146,9 +149,6 @@ qx.Class.define("qxapp.desktop.StudyBrowser", { }, __newStudyBtnClkd: function() { - if (!qxapp.data.Permissions.getInstance().canDo("studies.user.create", true)) { - return; - } if (this.__creatingNewStudy) { return; } @@ -248,7 +248,7 @@ qx.Class.define("qxapp.desktop.StudyBrowser", { __createUserStudyList: function() { // layout - let usrLst = this.__userStudyList = this.__creteStudyListLayout(); + let usrLst = this.__userStudyList = this.__createStudyListLayout(); usrLst.addListener("changeSelection", e => { if (e.getData() && e.getData().length>0) { this.__templateStudyList.resetSelection(); @@ -293,7 +293,7 @@ qx.Class.define("qxapp.desktop.StudyBrowser", { __createTemplateStudyList: function() { // layout - let tempList = this.__templateStudyList = this.__creteStudyListLayout(); + let tempList = this.__templateStudyList = this.__createStudyListLayout(); tempList.addListener("changeSelection", e => { if (e.getData() && e.getData().length>0) { this.__userStudyList.resetSelection(); @@ -365,7 +365,7 @@ qx.Class.define("qxapp.desktop.StudyBrowser", { studyCtr.setDelegate(delegate); }, - __creteStudyListLayout: function() { + __createStudyListLayout: function() { let list = new qx.ui.form.List().set({ orientation: "horizontal", spacing: 10, diff --git a/services/web/client/source/class/qxapp/io/request/ApiRequest.js b/services/web/client/source/class/qxapp/io/request/ApiRequest.js index 5fbcc9a4d21..a2d6876cfdb 100644 --- a/services/web/client/source/class/qxapp/io/request/ApiRequest.js +++ b/services/web/client/source/class/qxapp/io/request/ApiRequest.js @@ -17,8 +17,7 @@ /** * HTTP requests to simcore's rest API - * -*/ + */ qx.Class.define("qxapp.io.request.ApiRequest", { extend: qx.io.request.Xhr, diff --git a/services/web/client/source/class/qxapp/io/request/authentication/Token.js b/services/web/client/source/class/qxapp/io/request/authentication/Token.js new file mode 100644 index 00000000000..4fbe2a8e0b6 --- /dev/null +++ b/services/web/client/source/class/qxapp/io/request/authentication/Token.js @@ -0,0 +1,37 @@ +/* ************************************************************************ + + qxapp - the simcore frontend + + https://osparc.io + + Copyright: + 2019 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Ignacio Pascual (ignapas) + +************************************************************************ */ + +qx.Class.define("qxapp.io.request.authentication.Token", { + extend: qx.core.Object, + + implement: qx.io.request.authentication.IAuthentication, + + construct: function(token) { + this.__token = token; + }, + + members: { + __token: null, + + getAuthHeaders: function() { + return [{ + key: "Authorization", + value: "Bearer " + this.__token + }]; + } + } +}); diff --git a/services/web/client/source/class/qxapp/theme/Appearance.js b/services/web/client/source/class/qxapp/theme/Appearance.js index aedf001d6d3..02c23b37fd3 100644 --- a/services/web/client/source/class/qxapp/theme/Appearance.js +++ b/services/web/client/source/class/qxapp/theme/Appearance.js @@ -202,6 +202,25 @@ qx.Theme.define("qxapp.theme.Appearance", { style: state => ({ backgroundColor: "background-main-lighter+" }) + }, + + /* + --------------------------------------------------------------------------- + Buttons + --------------------------------------------------------------------------- + */ + "link-button": { + include: "material-button", + style: (state, style) => { + const ret = Object.assign({}, style); + ret.decorator = "link-button"; + ret.backgroundColor = "transparent"; + ret.textColor = "text-darker"; + if (state.hovered) { + ret.textColor = "text"; + } + return ret; + } } } }); diff --git a/services/web/client/source/class/qxapp/theme/Color.js b/services/web/client/source/class/qxapp/theme/Color.js index 6ba68ade8bd..6853731d816 100644 --- a/services/web/client/source/class/qxapp/theme/Color.js +++ b/services/web/client/source/class/qxapp/theme/Color.js @@ -29,6 +29,7 @@ qx.Theme.define("qxapp.theme.Color", { "logger-error-message": "#F00", "background-main-lighter": "#2D2D2D", "background-main-lighter+": "#373737", - "text-placeholder": "text-disabled" + "text-placeholder": "text-disabled", + "text-darker": "text-disabled" } }); diff --git a/services/web/client/source/class/qxapp/theme/Decoration.js b/services/web/client/source/class/qxapp/theme/Decoration.js index 5d91243b59b..74fc538b437 100644 --- a/services/web/client/source/class/qxapp/theme/Decoration.js +++ b/services/web/client/source/class/qxapp/theme/Decoration.js @@ -73,6 +73,8 @@ qx.Theme.define("qxapp.theme.Decoration", { transitionDuration: "0.2s", transitionTimingFunction: "ease-in" } - } + }, + + "link-button": {} } }); diff --git a/services/web/client/source/class/qxapp/ui/form/LinkButton.js b/services/web/client/source/class/qxapp/ui/form/LinkButton.js index a20f16d07e1..a4a369da783 100644 --- a/services/web/client/source/class/qxapp/ui/form/LinkButton.js +++ b/services/web/client/source/class/qxapp/ui/form/LinkButton.js @@ -40,13 +40,15 @@ qx.Class.define("qxapp.ui.form.LinkButton", { this.base(arguments, label); this.set({ - icon: "@FontAwesome5Solid/external-link-alt/"+height, iconPosition: "right", allowGrowX: false }); - this.addListener("execute", () => { - window.open(url); - }, this); + if (url) { + this.setIcon("@FontAwesome5Solid/external-link-alt/" + height); + this.addListener("execute", () => { + window.open(url); + }, this); + } } }); diff --git a/services/web/client/source/class/qxapp/utils/Utils.js b/services/web/client/source/class/qxapp/utils/Utils.js index 214667826ea..733dbd0a31f 100644 --- a/services/web/client/source/class/qxapp/utils/Utils.js +++ b/services/web/client/source/class/qxapp/utils/Utils.js @@ -189,7 +189,7 @@ qx.Class.define("qxapp.utils.Utils", { }, /** - * Function that takes an indefinite number of strings, and concatenates them capitalizing the first letter. + * Function that takes an indefinite number of strings as separated parameters, and concatenates them capitalizing the first letter. */ capitalize: function() { let res = ""; @@ -263,6 +263,34 @@ qx.Class.define("qxapp.utils.Utils", { document.body.removeChild(textArea); return copied; + }, + + cookie: { + setCookie: (cname, cvalue, exdays) => { + var d = new Date(); + d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)); + var expires = "expires="+d.toUTCString(); + document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/"; + }, + + getCookie: cname => { + var name = cname + "="; + var ca = document.cookie.split(";"); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == " ") { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ""; + }, + + deleteCookie: cname => { + document.cookie = cname + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + } } } }); diff --git a/services/web/server/src/simcore_service_webserver/application.py b/services/web/server/src/simcore_service_webserver/application.py index 12eb8fd65d1..9314eec72db 100644 --- a/services/web/server/src/simcore_service_webserver/application.py +++ b/services/web/server/src/simcore_service_webserver/application.py @@ -23,6 +23,7 @@ from .statics import setup_statics from .storage import setup_storage from .users import setup_users +from .studies_access import setup_studies_access log = logging.getLogger(__name__) @@ -57,6 +58,7 @@ def create_application(config: dict): setup_storage(app) setup_users(app) setup_projects(app, enable_fake_data=True) # TODO: deactivate fakes i.e. debug=testing + setup_studies_access(app) if config['director']["enabled"]: setup_app_proxy(app) # TODO: under development!!! diff --git a/services/web/server/src/simcore_service_webserver/projects/__init__.py b/services/web/server/src/simcore_service_webserver/projects/__init__.py index e6910b57d1e..cee1fb5c4b8 100644 --- a/services/web/server/src/simcore_service_webserver/projects/__init__.py +++ b/services/web/server/src/simcore_service_webserver/projects/__init__.py @@ -1,26 +1,26 @@ """ projects management subsystem - +TODO: now they are called 'studies' """ import asyncio import logging from pprint import pformat -from aiohttp import web -from tenacity import retry, wait_fixed, stop_after_attempt, before_sleep_log +from tenacity import before_sleep_log, retry, stop_after_attempt, wait_fixed -from servicelib.application_keys import APP_CONFIG_KEY, APP_JSONSCHEMA_SPECS_KEY +from aiohttp import web +from servicelib.application_keys import (APP_CONFIG_KEY, + APP_JSONSCHEMA_SPECS_KEY) from servicelib.jsonschema_specs import create_jsonschema_specs from servicelib.rest_routing import (get_handlers_from_namespace, iter_path_operations, map_handlers_with_operations) -from .config import CONFIG_SECTION_NAME -from . import nodes_handlers, projects_handlers from ..rest_config import APP_OPENAPI_SPECS_KEY +from . import nodes_handlers, projects_handlers +from .config import CONFIG_SECTION_NAME from .projects_fakes import Fake - RETRY_WAIT_SECS = 2 RETRY_COUNT = 20 CONNECT_TIMEOUT_SECS = 30 @@ -47,10 +47,12 @@ def _create_routes(prefix, handlers_module, specs, disable_login): @retry( wait=wait_fixed(RETRY_WAIT_SECS), stop=stop_after_attempt(RETRY_COUNT), before_sleep=before_sleep_log(logger, logging.INFO) ) -async def get_specs(location): +async def _get_specs(location): specs = await create_jsonschema_specs(location) return specs + + def setup(app: web.Application, *, enable_fake_data=False, disable_login=False): """ :param app: main web application @@ -71,7 +73,7 @@ def setup(app: web.Application, *, enable_fake_data=False, disable_login=False): logger.warning("'%s' explicitly disabled in config", __name__) return - # routes + # API routes specs = app[APP_OPENAPI_SPECS_KEY] routes = _create_routes("/projects", projects_handlers, specs, disable_login) @@ -83,7 +85,7 @@ def setup(app: web.Application, *, enable_fake_data=False, disable_login=False): # get project jsonschema definition project_schema_location = cfg['location'] loop = asyncio.get_event_loop() - specs = loop.run_until_complete( get_specs(project_schema_location) ) + specs = loop.run_until_complete( _get_specs(project_schema_location) ) if APP_JSONSCHEMA_SPECS_KEY in app: app[APP_JSONSCHEMA_SPECS_KEY][CONFIG_SECTION_NAME] = specs else: diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_models.py b/services/web/server/src/simcore_service_webserver/projects/projects_models.py index ae9ccb1d5d6..c0688c586f6 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_models.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_models.py @@ -77,24 +77,44 @@ def _convert_to_schema_names(project_db_data) -> Dict: return converted_args class ProjectDB: + # TODO: should implement similar model as services/web/server/src/simcore_service_webserver/login/storage.py + @classmethod async def add_projects(cls, projects_list: List[Dict], user_id: str, db_engine): - """ adds all projects and assigns to a user + """ + adds all projects and assigns to a user + + If user_id is None, then project is added as Template """ log.info("adding projects to database for user %s", user_id) for prj in projects_list: - async with db_engine.acquire() as conn: - #FIXME: E1120:No value for argument 'dml' in method call - # pylint: disable=E1120 - query = projects.insert().values( - type = ProjectType.TEMPLATE if user_id is None else ProjectType.STANDARD, - **_convert_to_db_names(prj) - ) + await cls.add_project(prj, user_id, db_engine) - result = await conn.execute(query) - row = await result.fetchone() - project_id = row["id"] + @classmethod + async def add_project(cls, prj: Dict, user_id: str, db_engine): + """ Add project to user. + + If user_id is None, then project is added as template + + :raises ProjectInvalidRightsError: User has no permission to access project + """ + #FIXME: E1120:No value for argument 'dml' in method call + # pylint: disable=E1120 + + async with db_engine.acquire() as conn: + # TODO: check security of this query + kargs = { + "type": ProjectType.TEMPLATE if user_id is None else ProjectType.STANDARD, + } + kargs.update(_convert_to_db_names(prj)) + query = projects.insert().values(**kargs) + + result = await conn.execute(query) + row = await result.fetchone() + project_id = row["id"] + + if user_id is not None: try: query = user_to_projects.insert().values( user_id=user_id, @@ -102,19 +122,19 @@ async def add_projects(cls, projects_list: List[Dict], user_id: str, db_engine): await conn.execute(query) except IntegrityError as exc: log.exception("Unregistered user trying to add project") + # rollback projects database query = projects.delete().\ where(projects.c.id == project_id) await conn.execute(query) - raise ProjectInvalidRightsError(user_id, prj["uuid"]) from exc + raise ProjectInvalidRightsError(user_id, prj["uuid"]) from exc @classmethod async def load_user_projects(cls, user_id: str, db_engine) -> List[Dict]: """ loads a project for a user """ - log.info("Loading projects for user %s", user_id) projects_list = [] async with db_engine.acquire() as conn: @@ -147,8 +167,17 @@ async def load_template_projects(cls, db_engine) -> List[Dict]: @classmethod async def get_user_project(cls, user_id: str, project_uuid: str, db_engine) -> Dict: - """ gets a project from a user - + """[summary] + + :param user_id: [description] + :type user_id: str + :param project_uuid: [description] + :type project_uuid: str + :param db_engine: [description] + :type db_engine: [type] + :raises ProjectNotFoundError: project is not assigned to user + :return: project + :rtype: Dict """ log.info("Getting project %s for user %s", project_uuid, user_id) async with db_engine.acquire() as conn: @@ -203,9 +232,11 @@ async def delete_user_project(cls, user_id: str, project_uuid: str, db_engine): result = await conn.execute(query) # ensure we have found one rows = await result.fetchall() + if not rows: # no project found raise ProjectNotFoundError(project_uuid) + if len(rows) == 1: row = rows[0] # now let's delete the link to the user @@ -226,6 +257,7 @@ async def delete_user_project(cls, user_id: str, project_uuid: str, db_engine): query = projects.delete().\ where(projects.c.id == row[projects.c.id]) await conn.execute(query) + __all__ = ( "ProjectDB" ) diff --git a/services/web/server/src/simcore_service_webserver/studies_access.py b/services/web/server/src/simcore_service_webserver/studies_access.py new file mode 100644 index 00000000000..ed170bb2c44 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/studies_access.py @@ -0,0 +1,247 @@ +""" handles access to studies + + Handles a request to share a given sharable study via '/study/{id}' + + - defines '/study/{id}' routes (this route does NOT belong to restAPI) + - access to projects management subsystem + - access to statics + - access to security + - access to login + +FIXME: reduce modules coupling! See all TODO: .``from ...`` comments +TODO: THIS IS A PROTOTYPE!!! + +""" +import json +import logging +import uuid +from typing import Dict + +from aiohttp import web + +from .resources import resources +from .security import is_anonymous, remember +from .statics import INDEX_RESOURCE_NAME + +log = logging.getLogger(__name__) + +TEMPLATE_PREFIX = "template-uuid" +BASE_UUID = uuid.UUID("71e0eb5e-0797-4469-89ba-00a0df4d338a") + + +def load_isan_template_uuids(): + with resources.stream('data/fake-template-projects.isan.json') as fp: + data = json.load(fp) + return [prj['uuid'] for prj in data] + +SHARABLE_TEMPLATE_STUDY_IDS = load_isan_template_uuids() + +# TODO: from .projects import get_template_project +async def get_template_project(app: web.Application, project_uuid: str): + # TODO: remove projects_ prefix from name + from servicelib.application_keys import APP_DB_ENGINE_KEY + + from .projects.projects_models import ProjectDB + from .projects.projects_fakes import Fake + + + # TODO: user search queries in DB instead + # BUG: ensure items in project_list have unique UUIDs + projects_list = [prj.data for prj in Fake.projects.values() if prj.template] + projects_list += await ProjectDB.load_template_projects(db_engine=app[APP_DB_ENGINE_KEY]) + + for prj in projects_list: + if prj.get('uuid') == project_uuid: + return prj + return None + +# TODO: from .users import create_temporary_user +async def create_temporary_user(request: web.Request): + """ + TODO: user should have an expiration date and limited persmissions! + """ + from .login.cfg import get_storage + from .login.handlers import ACTIVE, ANONYMOUS + from .login.utils import get_client_ip, get_random_string + from .security import encrypt_password + # from .utils import generate_passphrase + # from .utils import generate_password + + db = get_storage(request.app) + + # TODO: avatar is an icon of the hero! + # FIXME: # username = generate_passphrase(number_of_words=2).replace(" ", "_").replace("'", "") + username = get_random_string(min_len=5) + email = username + "@guest-at-osparc.io" + # TODO: temporarily while developing, a fixed password + password = "guest" #generate_password() + + user = await db.create_user({ + 'name': username, + 'email': email, + 'password_hash': encrypt_password(password), + 'status': ACTIVE, + 'role': ANONYMOUS, # TODO: THIS has to be a temporary user! + 'created_ip': get_client_ip(request), + }) + + return user + +# TODO: from .users import get_user? +async def get_authorized_user(request: web.Request) -> Dict: + from .login.cfg import get_storage + from .security import authorized_userid + + db = get_storage(request.app) + userid = await authorized_userid(request) + user = await db.get_user({'id': userid}) + return user + +# Creation of projects from templates --- +def compose_uuid(template_uuid, user_id) -> str: + """ Creates a new uuid composing a project's and user ids such that + any template pre-assigned to a user + + LIMITATION: a user cannot have multiple copies of the same template + TODO: cache results + """ + new_uuid = str( uuid.uuid5(BASE_UUID, str(template_uuid) + str(user_id)) ) + return new_uuid + +def create_project_from_template(template_project, user): + """ Creates a copy of the template and prepares it + to be owned by a given user + """ + from copy import deepcopy + from .projects.projects_models import ProjectType + + user_id = user["id"] + + def _replace_uuids(node): + if isinstance(node, str): + if node.startswith(TEMPLATE_PREFIX): + node = compose_uuid(node, user_id) + elif isinstance(node, list): + node = [_replace_uuids(item) for item in node] + elif isinstance(node, dict): + _frozen_items = tuple(node.items()) + for key, value in _frozen_items: + if isinstance(key, str): + if key.startswith(TEMPLATE_PREFIX): + new_key = compose_uuid(key, user_id) + node[new_key] = node.pop(key) + key = new_key + node[key] = _replace_uuids(value) + return node + + project = deepcopy(template_project) + project = _replace_uuids(project) + + project["type"] = ProjectType.STANDARD + project["prj_owner"] = user["name"] + + return project + +# TODO: from .projects import ...? +async def copy_study_to_account(request: web.Request, template_project: Dict, user: Dict): + """ + Creates a copy of the study to a given project in user's account + + Contrains of this method: + - Avoids multiple copies of the same template on each account + """ + from servicelib.application_keys import APP_DB_ENGINE_KEY + + from .projects.projects_models import ProjectDB as db + from .projects.projects_exceptions import ProjectNotFoundError + + db_engine = request.app[APP_DB_ENGINE_KEY] + + # assign id to copy + project_uuid = compose_uuid(template_project["uuid"], user["id"]) + + try: + # Avoids multiple copies of the same template on each account + await db.get_user_project(user["id"], project_uuid, db_engine) + + except ProjectNotFoundError: + # new project from template + project = create_project_from_template(template_project, user) + + await db.add_project(project, user["id"], db_engine) + + return project_uuid + + +# ----------------------------------------------- + +async def access_study(request: web.Request) -> web.Response: + """ + Handles requests to access a study in a given user's account + + - study must be a template + - if user is not registered, it creates a temporary account (has an expiration date) + - + """ + study_id = request.match_info["id"] + + log.debug("Requested a copy of study '%s' ...", study_id) + + # FIXME: if identified user, then he can access not only to template but also his own projects! + if study_id not in SHARABLE_TEMPLATE_STUDY_IDS: + raise web.HTTPNotFound(reason="Requested study is not shared ['%s']" % study_id) + + # TODO: should copy **any** type of project is sharable -> get_sharable_project + template_project = await get_template_project(request.app, study_id) + if not template_project: + raise RuntimeError("Unable to load study %s" % study_id) + + user = None + is_anonymous_user = await is_anonymous(request) + if is_anonymous_user: + log.debug("Creating temporary user ...") + user = await create_temporary_user(request) + else: + user = await get_authorized_user(request) + + if not user: + raise RuntimeError("Unable to start user session") + + + log.debug("Copying study %s to %s account ...", template_project['name'], user["email"]) + copied_project_id = await copy_study_to_account(request, template_project, user) + + log.debug("Coped study %s to %s account as %s", + template_project['name'], user["email"], copied_project_id) + + + try: + loc = request.app.router[INDEX_RESOURCE_NAME].url_for().with_fragment("/study/{}".format(copied_project_id)) + except KeyError: + raise RuntimeError("Unable to serve front-end. Study has been anyway copied over to user.") + + response = web.HTTPFound(location=loc) + if is_anonymous_user: + log.debug("Auto login for anonymous user %s", user["name"]) + identity = user['email'] + await remember(request, response, identity) + + raise response + + + + +def setup(app: web.Application): + + # TODO: make sure that these routes are filtered properly in active middlewares + app.router.add_routes([ + web.get(r"/study/{id}", access_study, name="study"), + ]) + + +# alias +setup_studies_access = setup + +__all__ = ( + 'setup_studies_access' +) diff --git a/services/web/server/src/simcore_service_webserver/utils.py b/services/web/server/src/simcore_service_webserver/utils.py index 9ee00c8d53d..b28f979df12 100644 --- a/services/web/server/src/simcore_service_webserver/utils.py +++ b/services/web/server/src/simcore_service_webserver/utils.py @@ -3,8 +3,10 @@ """ import hashlib import os +import string import sys from pathlib import Path +from secrets import choice from typing import Iterable, List from yarl import URL @@ -83,3 +85,51 @@ def gravatar_hash(email): def gravatar_url(gravatarhash, size=100, default='identicon', rating='g') -> URL: url = URL('https://secure.gravatar.com/avatar/%s' % gravatarhash) return url.with_query(s=size, d=default, r=rating) + + +def generate_password(length: int=8, more_secure: bool=False) -> str: + """ generate random passord + + :param length: password length, defaults to 8 + :type length: int, optional + :param more_secure: if True it adds at least one lowercase, one uppercase and three digits, defaults to False + :type more_secure: bool, optional + :return: password + :rtype: str + """ + # Adapted from https://docs.python.org/3/library/secrets.html#recipes-and-best-practices + alphabet = string.ascii_letters + string.digits + + if more_secure: + # At least one lowercase, one uppercase and three digits + while True: + password = ''.join(choice(alphabet) for i in range(length)) + if (any(c.islower() for c in password) + and any(c.isupper() for c in password) + and sum(c.isdigit() for c in password) >= 3): + break + else: + password = ''.join(choice(alphabet) for i in range(length)) + + return password + + +def generate_passphrase(number_of_words=4): + # Adapted from https://docs.python.org/3/library/secrets.html#recipes-and-best-practices + words = load_words() + passphrase = ' '.join(choice(words) for i in range(number_of_words)) + return passphrase + + +def load_words(): + """ + ONLY in linux systems + + :return: a list of words + :rtype: list of str + """ + # BUG: alpine does not have this file. Get from https://users.cs.duke.edu/~ola/ap/linuxwords in container + assert ('linux' in sys.platform), "Function can only run on Linux systems." + with open('/usr/share/dict/words') as f: + words = [word.strip() for word in f] + return words diff --git a/services/web/server/tests/helpers/__init__.py b/services/web/server/tests/helpers/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/services/web/server/tests/helpers/utils_login.py b/services/web/server/tests/helpers/utils_login.py index 244127e3e19..294885d1bee 100644 --- a/services/web/server/tests/helpers/utils_login.py +++ b/services/web/server/tests/helpers/utils_login.py @@ -1,3 +1,5 @@ +import re + from aiohttp import web from yarl import URL @@ -7,8 +9,6 @@ get_random_string) from utils_assert import assert_status -import re - TEST_MARKS = re.compile(r'TEST (\w+):(.*)') def parse_test_marks(text): diff --git a/services/web/server/tests/helpers/utils_projects.py b/services/web/server/tests/helpers/utils_projects.py new file mode 100644 index 00000000000..6cb74bf09df --- /dev/null +++ b/services/web/server/tests/helpers/utils_projects.py @@ -0,0 +1,71 @@ +""" helpers to manage the projects's database and produce fixtures/mockup data for testing + + +SEE services/web/server/src/simcore_service_webserver/projects/projects_models.py + +""" +# pylint: disable=no-value-for-parameter + +import json +import re +from typing import Dict + +from aiohttp import web + +from simcore_service_webserver.projects.projects_models import \ + ProjectDB as storage +from simcore_service_webserver.resources import resources +from simcore_service_webserver.db import APP_DB_ENGINE_KEY + + +fake_template_resources = ['data/'+name for name in resources.listdir('data') + if re.match(r"^fake-template-(.+).json", name) ] + +fake_project_resources = ['data/'+name for name in resources.listdir('data') + if re.match(r"^fake-user-(.+).json", name) ] + + +def load_data(name): + with resources.stream(name) as fp: + return json.load(fp) + + +async def create_project(engine, params: Dict=None, user_id=None) -> Dict: + params = params or {} + + prj = load_data('data/fake-template-projects.isan.json')[0] + prj.update(params) + + await storage.add_project(prj, user_id, engine) + return prj + + +async def delete_all_projects(engine): + from simcore_service_webserver.projects.projects_models import projects, user_to_projects + + async with engine.acquire() as conn: + query = user_to_projects.delete() + await conn.execute(query) + + query = projects.delete() + await conn.execute(query) + + +class NewProject: + def __init__(self, params: Dict=None, app: web.Application=None, clear_all=True): + self.params = params + self.engine = app[APP_DB_ENGINE_KEY] + self.prj = {} + self.clear_all = clear_all + + if not self.clear_all: + # TODO: add delete_project. Deleting a single project implies having to delete as well all dependencies created + raise ValueError("UNDER DEVELOPMENT: Currently can only delete all projects ") + + async def __aenter__(self): + self.prj = await create_project(self.engine, self.params) + return self.prj + + async def __aexit__(self, *args): + if self.clear_all: + await delete_all_projects(self.engine) diff --git a/services/web/server/tests/integration/computation/test_computation.py b/services/web/server/tests/integration/computation/test_computation.py index 13de004d2f0..d97e7f5ca03 100644 --- a/services/web/server/tests/integration/computation/test_computation.py +++ b/services/web/server/tests/integration/computation/test_computation.py @@ -151,7 +151,7 @@ def _check_sleeper_services_completed(project_id, postgres_session): assert task_db.state == SUCCESS async def test_start_pipeline(sleeper_service, client, project_id:str, mock_workbench_payload, mock_workbench_adjacency_list, postgres_session, celery_service): - # import pdb; pdb.set_trace() + resp = await client.post("/{}/computation/pipeline/{}/start".format(API_VERSION, project_id), json = mock_workbench_payload, ) diff --git a/services/web/server/tests/integration/conftest.py b/services/web/server/tests/integration/conftest.py index 02f46c23406..9da830b34cd 100644 --- a/services/web/server/tests/integration/conftest.py +++ b/services/web/server/tests/integration/conftest.py @@ -26,11 +26,17 @@ "fixtures.postgres_service" ] +log = logging.getLogger(__name__) + sys.path.append(str(Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent.parent / 'helpers')) -log = logging.getLogger(__name__) API_VERSION = "v0" + +@pytest.fixture(scope='session') +def here(): + return Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent + @pytest.fixture(scope="module") def webserver_environ(request, devel_environ, services_docker_compose) -> Dict[str, str]: """ Environment variables for the webserver application diff --git a/services/web/server/tests/integration/fixtures/docker_compose.py b/services/web/server/tests/integration/fixtures/docker_compose.py index e75d073896b..82cc4c9cd9e 100644 --- a/services/web/server/tests/integration/fixtures/docker_compose.py +++ b/services/web/server/tests/integration/fixtures/docker_compose.py @@ -66,12 +66,13 @@ def docker_compose_file(request, temp_folder, services_docker_compose, devel_env """ Overrides pytest-docker fixture """ - core_services = getattr(request.module, 'core_services', []) + core_services = getattr(request.module, 'core_services', []) # TODO: PC->SAN could also be defined as a fixture (as with docker_compose) docker_compose_path = temp_folder / 'docker-compose.yml' - # docker_compose_path = tmp_path / 'docker-compose.yml' + _recreate_compose_file(core_services, services_docker_compose, docker_compose_path, devel_environ) yield Path(docker_compose_path) + # cleanup # docker_compose_path.unlink() @@ -86,6 +87,7 @@ def tools_docker_compose_file(request, temp_folder, tools_docker_compose, devel_ _recreate_compose_file(tool_services, tools_docker_compose, docker_compose_path, devel_environ) yield Path(docker_compose_path) + # cleanup # Path(docker_compose_path).unlink() diff --git a/services/web/server/tests/integration/projects/test_access_to_studies.py b/services/web/server/tests/integration/projects/test_access_to_studies.py new file mode 100644 index 00000000000..59a8ccc6cd2 --- /dev/null +++ b/services/web/server/tests/integration/projects/test_access_to_studies.py @@ -0,0 +1,262 @@ +""" Covers user stories for ISAN : #501, #712, #730 + +""" +# pylint:disable=wildcard-import +# pylint:disable=unused-import +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name + +import textwrap +from pathlib import Path +from pprint import pprint +from typing import Dict + +import pytest + +import simcore_service_webserver.statics +import simcore_service_webserver.studies_access +from aiohttp import web +from servicelib.application_keys import APP_CONFIG_KEY +from servicelib.rest_responses import unwrap_envelope +from simcore_service_webserver import studies_access +from simcore_service_webserver.db import setup_db +from simcore_service_webserver.login import setup_login +from simcore_service_webserver.projects import setup_projects +from simcore_service_webserver.projects.projects_models import ProjectType +from simcore_service_webserver.rest import setup_rest +from simcore_service_webserver.security import setup_security +from simcore_service_webserver.session import setup_session +from simcore_service_webserver.statics import setup_statics +from simcore_service_webserver.studies_access import (TEMPLATE_PREFIX, + get_template_project, + setup_studies_access) +from simcore_service_webserver.users import setup_users +from utils_assert import assert_status +from utils_login import LoggedUser, UserRole +from utils_projects import NewProject + +# Selection of core and tool services started in this swarm fixture (integration) +core_services = [ + 'apihub', + 'postgres' +] + +tool_services = [ + 'adminer' +] + + +STUDY_UUID = TEMPLATE_PREFIX + "THIS_IS_A_FAKE_STUDY_FOR_TESTING_UUID" + + +@pytest.fixture +def qx_client_outdir(tmpdir, mocker): + """ Emulates qx output at service/web/client after compiling """ + + basedir = tmpdir.mkdir("source-output") + folders = [ basedir.mkdir(folder_name) for folder_name in ('qxapp', 'resource', 'transpiled')] + + index_file = Path( basedir.join("index.html") ) + index_file.write_text(textwrap.dedent("""\ + + + +
This is a result of qx_client_outdir fixture
+ + + """)) + + # patch get_client_outdir + mocker.patch.object(simcore_service_webserver.statics, "get_client_outdir") + simcore_service_webserver.statics.get_client_outdir.return_value = Path(basedir) + + +@pytest.fixture +def webserver_service(loop, docker_stack, aiohttp_server, aiohttp_unused_port, api_specs_dir, app_config, qx_client_outdir): +##def webserver_service(loop, aiohttp_server, aiohttp_unused_port, api_specs_dir, app_config, qx_client_outdir): # <<=======OFFLINE DEV + port = app_config["main"]["port"] = aiohttp_unused_port() + app_config['main']['host'] = '127.0.0.1' + + app_config['storage']['enabled'] = False + app_config['rabbit']['enabled'] = False + + app = web.Application() + app[APP_CONFIG_KEY] = app_config + setup_statics(app) + setup_db(app) + setup_session(app) + setup_security(app) + setup_rest(app, debug=True) # TODO: why should we need this?? + setup_login(app) + setup_users(app) + setup_projects(app, + enable_fake_data=False, # no fake data + disable_login=False + ) + setup_studies_access(app) + + yield loop.run_until_complete( aiohttp_server(app, port=port) ) + + +@pytest.fixture +def client(loop, webserver_service, aiohttp_client, monkeypatch): + client = loop.run_until_complete(aiohttp_client(webserver_service)) + + assert studies_access.SHARABLE_TEMPLATE_STUDY_IDS, "Did u change the name again?" + monkeypatch.setattr(studies_access, 'SHARABLE_TEMPLATE_STUDY_IDS', [STUDY_UUID, ]) + + yield client + + + + + + +# TESTS -------------------------------------- +async def test_access_to_invalid_study(client): + resp = await client.get("/study/SOME_INVALID_UUID") + content = await resp.text() + + assert resp.status == web.HTTPNotFound.status_code, str(content) + + +async def test_access_to_forbidden_study(client): + app = client.app + + VALID_BUT_NON_SHARABLE_STUDY_UUID = "8402b4e0-3659-4e36-bc26-c4312f02f05f" + params = { + "uuid": VALID_BUT_NON_SHARABLE_STUDY_UUID + } + + async with NewProject(params, app) as expected_prj: + + resp = await client.get("/study/%s" % VALID_BUT_NON_SHARABLE_STUDY_UUID) + content = await resp.text() + + assert resp.status == web.HTTPNotFound.status_code, \ + "STANDARD studies are NOT sharable: %s" % content + + +async def _get_user_projects(client): + url = client.app.router["list_projects"].url_for() + resp = await client.get(url.with_query(start=0, count=3)) + payload = await resp.json() + assert resp.status == 200, payload + + projects, error = unwrap_envelope(payload) + assert not error, pprint(error) + + return projects + +def _assert_same_projects(got: Dict, expected: Dict): + # TODO: validate using api/specs/webserver/v0/components/schemas/project-v0.0.1.json + # TODO: validate workbench! + PROPERTIES_TO_CHECK = [ + "name", + "description", + "notes", + "collaborators", + "creationDate", + "lastChangeDate", + "thumbnail" + ] + for key in PROPERTIES_TO_CHECK: + assert got[key] == expected[key] + + +async def test_access_study_by_anonymous(client, qx_client_outdir): + app = client.app + params = { + "uuid":STUDY_UUID, + "name":"some-template" + } + + async with NewProject(params, app) as expected_prj: + + url_path = "/study/%s" % STUDY_UUID + resp = await client.get(url_path) + content = await resp.text() + + # index + assert resp.status == web.HTTPOk.status_code, "Got %s" % str(content) + assert str(resp.url.path) == "/" + assert "OSPARC-SIMCORE" in content, \ + "Expected front-end rendering workbench's study, got %s" % str(content) + + real_url = str(resp.real_url) + + # has auto logged in as guest? + resp = await client.get("/v0/me") + data, _ = await assert_status(resp, web.HTTPOk) + assert data['login'].endswith("guest-at-osparc.io") + assert data['gravatar_id'] + assert data['role'].upper() == UserRole.ANONYMOUS.name + + # anonymous user only a copy of the template project + projects = await _get_user_projects(client) + assert len(projects) == 1 + got_prj = projects[0] + + assert real_url.endswith("#/study/%s" % got_prj["uuid"]) + _assert_same_projects(got_prj, expected_prj) + + + +async def test_access_study_by_logged_user(client, qx_client_outdir): + app = client.app + params = { + "uuid":STUDY_UUID, + "name":"some-template" + } + + async with LoggedUser(client): + async with NewProject(params, app, clear_all=True) as expected_prj: + + url_path = "/study/%s" % STUDY_UUID + resp = await client.get(url_path) + content = await resp.text() + + # returns index + assert resp.status == web.HTTPOk.status_code, "Got %s" % str(content) + assert str(resp.url.path) == "/" + real_url = str(resp.real_url) + + assert "OSPARC-SIMCORE" in content, \ + "Expected front-end rendering workbench's study, got %s" % str(content) + + # user has a copy of the template project + projects = await _get_user_projects(client) + assert len(projects) == 1 + got_prj = projects[0] + + # TODO: check redirects to /#/study/{uuid} + assert real_url.endswith("#/study/%s" % got_prj["uuid"]) + + _assert_same_projects(got_prj, expected_prj) + + + +async def test_devel(client): + app = client.app + params = { + "uuid":STUDY_UUID, + "name":"some-template" + } + + async with NewProject(params, app) as expected_prj: + + prj = await get_template_project(app, STUDY_UUID) + + assert prj is not None + assert prj["uuid"] == STUDY_UUID + assert prj == expected_prj + + +#async def test_access_template_by_loggedin(client, ): +# pass + + + +# # check if NO new anonymous user is created diff --git a/services/web/server/tests/requirements.txt b/services/web/server/tests/requirements.txt index 01c75e44c2f..14efe0b12b0 100644 --- a/services/web/server/tests/requirements.txt +++ b/services/web/server/tests/requirements.txt @@ -11,3 +11,4 @@ pytest~=3.6 pytest-cov~=2.5 pytest-docker~=0.6 pytest-mock~=1.10 +docker diff --git a/services/web/server/tests/sandbox/create_portal_markdown.py b/services/web/server/tests/sandbox/create_portal_markdown.py new file mode 100644 index 00000000000..4746cb5d12a --- /dev/null +++ b/services/web/server/tests/sandbox/create_portal_markdown.py @@ -0,0 +1,43 @@ + +""" This script produces a markdown document with links to template studies + + Aims to emulate links + +""" +import datetime +import json +import sys +from pathlib import Path + +from simcore_service_webserver.resources import resources + +MARKDOWN_FILENAME = "study_access_demo.md" +ISSUE = r"https://github.com/ITISFoundation/osparc-simcore/issues/" + +current_path = Path( sys.argv[0] if __name__ == "__main__" else __file__).resolve() + + +def write_list(hostname, url, data, fh): + print("## studies available @{}".format(hostname), file=fh) + print("", file=fh) + for prj in data: + print("- [{name}]({base_url}/study/{uuid})".format(base_url=url, **prj), file=fh) + print("", file=fh) + +def main(): + with resources.stream('data/fake-template-projects.isan.json') as fp: + data = json.load(fp) + + with open(MARKDOWN_FILENAME, "wt") as fh: + print("".format(current_path.name, datetime.datetime.utcnow()), file=fh) + print("# THE PORTAL Emulator\n", file=fh) + print("This pages is for testing purposes for issue [#{1}]({0}{1})\n".format(ISSUE, 715), file=fh) + + write_list('localhost', r'http://127.0.0.1:9081', data, fh) + write_list('master', r'http://osparc01.itis.ethz.ch:9081', data, fh) + write_list('staging', r'https://staging.io:9081', data, fh) + write_list('osparc.io', r'https://osparc.io', data, fh) + + +if __name__ == "__main__": + main() diff --git a/services/web/server/tests/samples/jupyter-proxy.py b/services/web/server/tests/sandbox/jupyter-proxy.py similarity index 100% rename from services/web/server/tests/samples/jupyter-proxy.py rename to services/web/server/tests/sandbox/jupyter-proxy.py diff --git a/services/web/server/tests/samples/paraview-proxy.py b/services/web/server/tests/sandbox/paraview-proxy.py similarity index 100% rename from services/web/server/tests/samples/paraview-proxy.py rename to services/web/server/tests/sandbox/paraview-proxy.py diff --git a/services/web/server/tests/sandbox/study_access_demo.md b/services/web/server/tests/sandbox/study_access_demo.md new file mode 100644 index 00000000000..0efeb7df9a9 --- /dev/null +++ b/services/web/server/tests/sandbox/study_access_demo.md @@ -0,0 +1,37 @@ + +# THE PORTAL Emulator + +This pages is for testing purposes for issue [#715](https://github.com/ITISFoundation/osparc-simcore/issues/715) + +## studies available @localhost + +- [ISAN: UCDavis use case: 0D](http://127.0.0.1:9081/study/template-uuid-1234-a1a7-f7d4f3a8f26b) +- [ISAN: UCDavis use cases: 1D, 2D](http://127.0.0.1:9081/study/template-uuid-41e9-a1a7-f7d4f3a8f26b) +- [ISAN: Kember use case](http://127.0.0.1:9081/study/template-uuid-4a0d-b88d-e80bfa272ebd) +- [ISAN: MattWard use case](http://127.0.0.1:9081/study/template-uuid-420d-b88d-e80bfa272ebd) +- [ISAN: 4x CC 0D](http://127.0.0.1:9081/study/template-uuid-40e1-bb95-c68f6358bfe2) + +## studies available @master + +- [ISAN: UCDavis use case: 0D](http://osparc01.itis.ethz.ch:9081/study/template-uuid-1234-a1a7-f7d4f3a8f26b) +- [ISAN: UCDavis use cases: 1D, 2D](http://osparc01.itis.ethz.ch:9081/study/template-uuid-41e9-a1a7-f7d4f3a8f26b) +- [ISAN: Kember use case](http://osparc01.itis.ethz.ch:9081/study/template-uuid-4a0d-b88d-e80bfa272ebd) +- [ISAN: MattWard use case](http://osparc01.itis.ethz.ch:9081/study/template-uuid-420d-b88d-e80bfa272ebd) +- [ISAN: 4x CC 0D](http://osparc01.itis.ethz.ch:9081/study/template-uuid-40e1-bb95-c68f6358bfe2) + +## studies available @staging + +- [ISAN: UCDavis use case: 0D](https://staging.io:9081/study/template-uuid-1234-a1a7-f7d4f3a8f26b) +- [ISAN: UCDavis use cases: 1D, 2D](https://staging.io:9081/study/template-uuid-41e9-a1a7-f7d4f3a8f26b) +- [ISAN: Kember use case](https://staging.io:9081/study/template-uuid-4a0d-b88d-e80bfa272ebd) +- [ISAN: MattWard use case](https://staging.io:9081/study/template-uuid-420d-b88d-e80bfa272ebd) +- [ISAN: 4x CC 0D](https://staging.io:9081/study/template-uuid-40e1-bb95-c68f6358bfe2) + +## studies available @osparc.io + +- [ISAN: UCDavis use case: 0D](https://osparc.io/study/template-uuid-1234-a1a7-f7d4f3a8f26b) +- [ISAN: UCDavis use cases: 1D, 2D](https://osparc.io/study/template-uuid-41e9-a1a7-f7d4f3a8f26b) +- [ISAN: Kember use case](https://osparc.io/study/template-uuid-4a0d-b88d-e80bfa272ebd) +- [ISAN: MattWard use case](https://osparc.io/study/template-uuid-420d-b88d-e80bfa272ebd) +- [ISAN: 4x CC 0D](https://osparc.io/study/template-uuid-40e1-bb95-c68f6358bfe2) + diff --git a/services/web/server/tests/unit/login/conftest.py b/services/web/server/tests/unit/login/conftest.py index 73c036c48b6..895bad3cf39 100644 --- a/services/web/server/tests/unit/login/conftest.py +++ b/services/web/server/tests/unit/login/conftest.py @@ -24,7 +24,8 @@ from simcore_service_webserver.db_models import confirmations, metadata, users from simcore_service_webserver.application_config import app_schema as app_schema -sys.path.append(str(Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent.parent.parent / 'helpers')) +tests_folder = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent.parent.parent +sys.path.append(str(tests_folder/ 'helpers')) @pytest.fixture(scope="session") def here(): diff --git a/services/web/server/tests/unit/login/test_users.py b/services/web/server/tests/unit/login/test_users.py index 7b5aea0ec82..dc9d7171eb1 100644 --- a/services/web/server/tests/unit/login/test_users.py +++ b/services/web/server/tests/unit/login/test_users.py @@ -115,7 +115,7 @@ async def test_get_profile(logged_user, client): url = client.app.router["get_my_profile"].url_for() assert str(url) == "/v0/me" - resp = await client.get(url) + resp = await client.get(url) data, _ = await assert_status(resp, web.HTTPOk) assert data['login'] == logged_user["email"] diff --git a/services/web/server/tests/unit/test_studies_access.py b/services/web/server/tests/unit/test_studies_access.py new file mode 100644 index 00000000000..96a2a1a09bf --- /dev/null +++ b/services/web/server/tests/unit/test_studies_access.py @@ -0,0 +1,88 @@ +""" Covers user stories for ISAN : #501, #712, #730 + +""" +# pylint:disable=wildcard-import +# pylint:disable=unused-import +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name + + +from simcore_service_webserver.studies_access import create_project_from_template, compose_uuid + +from simcore_service_webserver.resources import resources + +import json +import pytest + + + +def isan_template_projects(): + projects = [] + with resources.stream('data/fake-template-projects.isan.json') as fp: + projects = json.load(fp) + return projects + + +@pytest.mark.parametrize("name,template_project", + [(p['name'], p) for p in isan_template_projects()[-1:]] ) +def test_create_from_template(name, template_project): + + user = { + 'id': 0, + 'name': 'foo', + 'email': 'foo@b.com' + } + + project = create_project_from_template(template_project, user) + + assert project["prj_owner"] == user["name"], "did not mark user's ownership" + + # if this template is taken by the same user, it returns smae project + expected_project_uuid = compose_uuid(template_project["uuid"], user["id"]) + assert project["uuid"] == expected_project_uuid + + + + +@pytest.mark.travis +def test_WHILE_DEVELOPMENT(): + + from simcore_service_webserver.studies_access import TEMPLATE_PREFIX + user_id = 55 + + def _replace_uuids(node): + if isinstance(node, str): + if node.startswith(TEMPLATE_PREFIX): + node = compose_uuid(node, user_id) + elif isinstance(node, list): + node = [_replace_uuids(item) for item in node] + elif isinstance(node, dict): + _frozen_items = tuple(node.items()) + for key, value in _frozen_items: + if isinstance(key, str): + if key.startswith(TEMPLATE_PREFIX): + new_key = compose_uuid(key, user_id) + node[new_key] = node.pop(key) + key = new_key + node[key] = _replace_uuids(value) + return node + + + data = { + TEMPLATE_PREFIX+'asdfasdf': TEMPLATE_PREFIX+'asdfasdf' + } + got = _replace_uuids(data) + assert list(got.keys()) == list(got.values()) + + + got1 = _replace_uuids([data, data]) + assert isinstance(got1, list) + assert got1 == [got, got] + + + prjs = isan_template_projects() + assert TEMPLATE_PREFIX in str(prjs) + + new_prjs = _replace_uuids(prjs) + assert TEMPLATE_PREFIX not in str(new_prjs)