diff --git a/docker/Dockerfile b/docker/Dockerfile index 5afa7a6e..021ac7e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -21,7 +21,6 @@ RUN \ RUN \ pip install virtualenv tox ipython ipdb httpie && \ - npm install -g bower && \ ln -s /usr/bin/nodejs /usr/bin/node ADD /docker/scripts/* /usr/bin/ diff --git a/docker/nginx/refstack-site.conf b/docker/nginx/refstack-site.conf index eafa11ce..d60bbc97 100644 --- a/docker/nginx/refstack-site.conf +++ b/docker/nginx/refstack-site.conf @@ -1,19 +1,23 @@ server { - server_name 127.0.0.1; - listen 443 ssl; + proxy_connect_timeout 600; + proxy_send_timeout 600; + proxy_read_timeout 600; + send_timeout 600; + server_name 127.0.0.1; + listen 443 ssl; - ssl on; - ssl_certificate /etc/nginx/certificates/refstack_dev.crt; - ssl_certificate_key /etc/nginx/certificates/refstack_dev.key; - ssl_protocols TLSv1.1 TLSv1.2; - ssl_ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AES:RSA+3DES:!ADH:!AECDH:!MD5:!DSS:!RC4; - ssl_prefer_server_ciphers on; - ssl_session_timeout 5m; - location / { - access_log off; - proxy_pass http://127.0.0.1:8000; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } + ssl on; + ssl_certificate /etc/nginx/certificates/refstack_dev.crt; + ssl_certificate_key /etc/nginx/certificates/refstack_dev.key; + ssl_protocols TLSv1.1 TLSv1.2; + ssl_ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AES:RSA+3DES:!ADH:!AECDH:!MD5:!DSS:!RC4; + ssl_prefer_server_ciphers on; + ssl_session_timeout 5m; + location / { + access_log off; + proxy_pass http://127.0.0.1:8000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } } \ No newline at end of file diff --git a/docker/scripts/api-up b/docker/scripts/api-up index 861d557f..8fb700f5 100755 --- a/docker/scripts/api-up +++ b/docker/scripts/api-up @@ -2,4 +2,4 @@ [[ ${DEBUG_MODE} ]] && set -x api-sync cd /home/dev/refstack -.venv/bin/pecan serve refstack/api/config.py > /dev/null +.venv/bin/pecan serve refstack/api/config.py diff --git a/docker/scripts/start.sh b/docker/scripts/start.sh index a98551c1..26bdb7f5 100755 --- a/docker/scripts/start.sh +++ b/docker/scripts/start.sh @@ -41,7 +41,8 @@ build_refstack_env () { #Install some dev tools .venv/bin/pip install ipython ipdb httpie cd /home/dev/refstack - bower install --config.interactive=false + npm install +# bower install --config.interactive=false build_tmpl /refstack/docker/templates/config.json.tmpl /home/dev/refstack/refstack-ui/app/config.json build_tmpl /refstack/docker/templates/refstack.conf.tmpl /home/dev/refstack.conf diff --git a/docker/templates/refstack.conf.tmpl b/docker/templates/refstack.conf.tmpl index eb0eb9eb..f29d30a7 100644 --- a/docker/templates/refstack.conf.tmpl +++ b/docker/templates/refstack.conf.tmpl @@ -1,12 +1,16 @@ [DEFAULT] debug = true verbose = true +ui_url = https://${REFSTACK_HOST:-127.0.0.1} [api] static_root = /home/dev/refstack/refstack-ui/app template_path = /home/dev/refstack/refstack-ui/app app_dev_mode = true -test_results_url = https://${REFSTACK_HOST:-127.0.0.1}/#/results/%s +api_url = https://${REFSTACK_HOST:-127.0.0.1} [database] connection = ${REFSTACK_MYSQL_URL} + +[osid] +openstack_openid_endpoint = https://172.17.42.1:8443/accounts/openid2 diff --git a/etc/refstack.conf.sample b/etc/refstack.conf.sample index 7f2309f3..748da7c8 100644 --- a/etc/refstack.conf.sample +++ b/etc/refstack.conf.sample @@ -40,14 +40,16 @@ #log_dir = # Use syslog for logging. Existing syslog format is DEPRECATED during -# I, and will change in J to honor RFC5424. (boolean value) +# I, and changed in J to honor RFC5424. (boolean value) #use_syslog = false # (Optional) Enables or disables syslog rfc5424 format for logging. If # enabled, prefixes the MSG part of the syslog message with APP-NAME -# (RFC5424). The format without the APP-NAME is deprecated in I, and -# will be removed in J. (boolean value) -#use_syslog_rfc_format = false +# (RFC5424). The format without the APP-NAME is deprecated in K, and +# will be removed in M, along with this option. (boolean value) +# This option is deprecated for removal. +# Its value may be silently ignored in the future. +#use_syslog_rfc_format = true # Syslog facility to receive log lines. (string value) #syslog_log_facility = LOG_USER @@ -67,7 +69,7 @@ # Prefix each line of exception output with this format. (string # value) -#logging_exception_prefix = %(asctime)s.%(msecs)03d %(process)d TRACE %(name)s %(instance)s +#logging_exception_prefix = %(asctime)s.%(msecs)03d %(process)d ERROR %(name)s %(instance)s # List of logger=LEVEL pairs. (list value) #default_log_levels = amqp=WARN,amqplib=WARN,boto=WARN,qpid=WARN,sqlalchemy=WARN,suds=INFO,oslo.messaging=INFO,iso8601=WARN,requests.packages.urllib3.connectionpool=WARN,urllib3.connectionpool=WARN,websocket=WARN,requests.packages.urllib3.util.retry=WARN,urllib3.util.retry=WARN,keystonemiddleware=WARN,routes.middleware=WARN,stevedore=WARN @@ -86,10 +88,17 @@ # (string value) #instance_uuid_format = "[instance: %(uuid)s] " +# Enables or disables fatal status of deprecations. (boolean value) +#fatal_deprecations = false + # # From refstack # +# Url of user interface for Refstack. Need for redirects after sign in +# and sign out. (string value) +#ui_url = http://refstack.net + # The backend to use for database. (string value) #db_backend = sqlalchemy @@ -100,6 +109,9 @@ # From refstack # +# Url of public Refstack API. (string value) +#api_url = http://api.refstack.net + # The directory where your static files can be found. Pecan comes with # middleware that can be used to serve static files (like CSS and # Javascript files) during development. %(project_root)s is special @@ -116,9 +128,9 @@ # relative the project root. (string value) #template_path = %(project_root)s/templates -# List of sites allowed cross-origin resource access. If this is empty, -# only same-origin requests are allowed. -#allowed_cors_origins = http://refstack.net, http://localhost:8080 +# List of sites allowed cross-site resource access. If this is empty, +# only same-origin requests are allowed. (list value) +#allowed_cors_origins = # Switch Refstack app into debug mode. Helpful for development. In # debug mode static file will be served by pecan application. Also, @@ -127,7 +139,17 @@ #app_dev_mode = false # Template for test result url. (string value) -#test_results_url = http://refstack.net/output.html?test_id=%s +#test_results_url = /#/results/%s + +# The GitHub API URL of the repository and location of the DefCore +# capability files. This URL is used to get a listing of all +# capability files. (string value) +#github_api_capabilities_url = https://api.github.com/repos/openstack/defcore/contents + +# This is the base URL that is used for retrieving specific capability +# files. Capability file names will be appended to this URL to get the +# contents of that file. (string value) +#github_raw_base_url = https://raw.githubusercontent.com/openstack/defcore/master/ # Number of results for one page (integer value) #results_per_page = 20 @@ -135,15 +157,6 @@ # The format for start_date and end_date parameters (string value) #input_date_format = %Y-%m-%d %H:%M:%S -# The GitHub API URL of the repository and location of the DefCore -# capability files. This URL is used to get a listing of all capability -# files. -#github_api_capabilities_url = https://api.github.com/repos/openstack/defcore/contents - -# The base URL that is used for retrieving specific capability files. -# Capability file names will be appended to this URL to get the contents -# of that file. -#github_raw_base_url = https://raw.githubusercontent.com/openstack/defcore/master/ [database] @@ -249,3 +262,51 @@ # error is raised. Set to -1 to specify an infinite retry count. # (integer value) #db_max_retries = 20 + + +[osid] + +# +# From refstack +# + +# OpenStackID Auth Server URI. (string value) +#openstack_openid_endpoint = https://openstackid.org/accounts/openid2 + +# Interaction mode. Specifies whether Openstack Id IdP may interact +# with the user to determine the outcome of the request. (string +# value) +#openid_mode = checkid_setup + +# Protocol version. Value identifying the OpenID protocol version +# being used. This value should be "http://specs.openid.net/auth/2.0". +# (string value) +#openid_ns = http://specs.openid.net/auth/2.0 + +# Return endpoint in Refstack's API. Value indicating the endpoint +# where the user should be returned to after signing in. Openstack Id +# Idp only supports HTTPS address types. (string value) +#openid_return_to = /v1/auth/signin_return + +# Claimed identifier. This value must be set to +# "http://specs.openid.net/auth/2.0/identifier_select". or to user +# claimed identity (user local identifier or user owned identity [ex: +# custom html hosted on a owned domain set to html discover]). (string +# value) +#openid_claimed_id = http://specs.openid.net/auth/2.0/identifier_select + +# Alternate identifier. This value must be set to +# http://specs.openid.net/auth/2.0/identifier_select. (string value) +#openid_identity = http://specs.openid.net/auth/2.0/identifier_select + +# Indicates request for user attribute information. This value must be +# set to "http://openid.net/extensions/sreg/1.1". (string value) +#openid_ns_sreg = http://openid.net/extensions/sreg/1.1 + +# Comma-separated list of field names which, if absent from the +# response, will prevent the Consumer from completing the registration +# without End User interation. The field names are those that are +# specified in the Response Format, with the "openid.sreg." prefix +# removed. Valid values include: "country", "email", "firstname", +# "language", "lastname" (string value) +#openid_sreg_required = email,fullname diff --git a/refstack-ui/app/app.js b/refstack-ui/app/app.js index d9a4f55b..ea6fd192 100644 --- a/refstack-ui/app/app.js +++ b/refstack-ui/app/app.js @@ -35,6 +35,29 @@ refstackApp.config([ url: '/results/:testID', templateUrl: '/components/results-report/resultsReport.html', controller: 'resultsReportController' + }). + state('profile', { + url: '/profile', + templateUrl: '/components/profile/profile.html', + controller: 'profileController' + }); + } +]); + +/** + * Try to authenticate user + */ + +refstackApp.run(['$http', '$rootScope', 'refstackApiUrl', + function($http, $rootScope, refstackApiUrl) { + 'use strict'; + var profile_url = refstackApiUrl + '/profile'; + $http.get(profile_url, {withCredentials: true}). + success(function(data) { + $rootScope.currentUser = data; + }). + error(function() { + $rootScope.currentUser = null; }); } ]); diff --git a/refstack-ui/app/components/auth/authController.js b/refstack-ui/app/components/auth/authController.js new file mode 100644 index 00000000..19ea009f --- /dev/null +++ b/refstack-ui/app/components/auth/authController.js @@ -0,0 +1,29 @@ +var refstackApp = angular.module('refstackApp'); + + /** + * Refstack Auth Controller + * This controller handles account authentication for users. + */ + +refstackApp.controller('authController', + ['$scope', '$window', '$rootScope', 'refstackApiUrl', + function($scope, $window, $rootScope, refstackApiUrl){ + 'use strict'; + var sign_in_url = refstackApiUrl + '/auth/signin'; + $scope.doSignIn = function () { + $window.location.href = sign_in_url; + }; + + var sign_out_url = refstackApiUrl + '/auth/signout'; + $scope.doSignOut = function () { + $rootScope.currentUser = null; + $window.location.href = sign_out_url; + }; + + $scope.isAuthenticated = function () { + if ($scope.currentUser) { + return !!$scope.currentUser.openid; + } + return false; + }; + }]); diff --git a/refstack-ui/app/components/profile/profile.html b/refstack-ui/app/components/profile/profile.html new file mode 100644 index 00000000..194a696c --- /dev/null +++ b/refstack-ui/app/components/profile/profile.html @@ -0,0 +1,4 @@ +

Hello, {{user.fullname}}!

+{{bar}} +

openid: {{user.openid}}

+

email: {{user.email}}

diff --git a/refstack-ui/app/components/profile/profileController.js b/refstack-ui/app/components/profile/profileController.js new file mode 100644 index 00000000..e5856bcc --- /dev/null +++ b/refstack-ui/app/components/profile/profileController.js @@ -0,0 +1,21 @@ + /** + * Refstack User Profile Controller + * This controller handles user's profile page, where a user can view + * account-specific information. + */ + +var refstackApp = angular.module('refstackApp'); + +refstackApp.controller('profileController', + ['$scope', '$http', 'refstackApiUrl', '$state', + function($scope, $http, refstackApiUrl, $state) { + 'use strict'; + var profile_url = refstackApiUrl + '/profile'; + $http.get(profile_url, {withCredentials: true}). + success(function(data) { + $scope.user = data; + }). + error(function() { + $state.go('home'); + }); + }]); diff --git a/refstack-ui/app/index.html b/refstack-ui/app/index.html index 314a3d43..0176c11d 100644 --- a/refstack-ui/app/index.html +++ b/refstack-ui/app/index.html @@ -40,6 +40,8 @@ + + diff --git a/refstack-ui/app/shared/header/header.html b/refstack-ui/app/shared/header/header.html index a42ddd6f..866aa7fc 100644 --- a/refstack-ui/app/shared/header/header.html +++ b/refstack-ui/app/shared/header/header.html @@ -20,6 +20,11 @@ Refstack
  • DefCore Capabilities
  • Community Results
  • + diff --git a/refstack-ui/tests/unit/ControllerSpec.js b/refstack-ui/tests/unit/ControllerSpec.js index 1b8f568e..c02cc900 100644 --- a/refstack-ui/tests/unit/ControllerSpec.js +++ b/refstack-ui/tests/unit/ControllerSpec.js @@ -2,9 +2,16 @@ describe('Refstack controllers', function () { 'use strict'; + var fakeApiUrl = 'http://foo.bar/v1'; + beforeEach(function () { + module(function ($provide) { + $provide.constant('refstackApiUrl', fakeApiUrl); + }); + module('refstackApp'); + }); + describe('headerController', function () { var scope, $location; - beforeEach(module('refstackApp')); beforeEach(inject(function ($rootScope, $controller, _$location_) { scope = $rootScope.$new(); @@ -29,15 +36,39 @@ describe('Refstack controllers', function () { }); }); + describe('authController', function () { + var scope, $httpBackend, $window; + + beforeEach(inject(function (_$httpBackend_, $rootScope, $controller) { + $httpBackend = _$httpBackend_; + scope = $rootScope.$new(); + $window = {location: { href: jasmine.createSpy()} }; + $controller('authController', {$scope: scope, $window: $window}); + })); + + it('should show signin url for signed user', function () { + $httpBackend.expectGET(fakeApiUrl + + '/profile').respond({'openid': 'foo@bar.com', + 'email': 'foo@bar.com', + 'fullname': 'foo' }); + $httpBackend.flush(); + scope.doSignIn(); + expect($window.location.href).toBe(fakeApiUrl + '/auth/signin'); + expect(scope.isAuthenticated()).toBe(true); + }); + + it('should show signout url for not signed user', function () { + $httpBackend.expectGET(fakeApiUrl + + '/profile').respond(401); + $httpBackend.flush(); + scope.doSignOut(); + expect($window.location.href).toBe(fakeApiUrl + '/auth/signout'); + expect(scope.isAuthenticated()).toBe(false); + }); + }); + describe('capabilitiesController', function () { var scope, $httpBackend; - var fakeApiUrl = 'http://foo.bar/v1'; - beforeEach(function () { - module('refstackApp'); - module(function ($provide) { - $provide.constant('refstackApiUrl', fakeApiUrl); - }); - }); beforeEach(inject(function (_$httpBackend_, $rootScope, $controller) { $httpBackend = _$httpBackend_; @@ -56,6 +87,8 @@ describe('Refstack controllers', function () { }); it('should fetch the selected capabilities version', function () { + $httpBackend.expectGET(fakeApiUrl + + '/profile').respond(401); $httpBackend.expectGET(fakeApiUrl + '/capabilities').respond(['2015.03.json', '2015.04.json']); // Should call request with latest version. @@ -115,7 +148,6 @@ describe('Refstack controllers', function () { describe('resultsController', function () { var scope, $httpBackend; - var fakeApiUrl = 'http://foo.bar/v1'; var fakeResponse = { 'pagination': {'current_page': 1, 'total_pages': 2}, 'results': [{ @@ -125,13 +157,6 @@ describe('Refstack controllers', function () { }] }; - beforeEach(function () { - module('refstackApp'); - module(function ($provide) { - $provide.constant('refstackApiUrl', fakeApiUrl); - }); - }); - beforeEach(inject(function (_$httpBackend_, $rootScope, $controller) { $httpBackend = _$httpBackend_; scope = $rootScope.$new(); @@ -141,6 +166,7 @@ describe('Refstack controllers', function () { it('should fetch the first page of results with proper URL args', function () { // Initial results should be page 1 of all results. + $httpBackend.expectGET(fakeApiUrl + '/profile').respond(401); $httpBackend.expectGET(fakeApiUrl + '/results?page=1').respond(fakeResponse); $httpBackend.flush(); @@ -162,6 +188,7 @@ describe('Refstack controllers', function () { }); it('should set an error when results cannot be retrieved', function () { + $httpBackend.expectGET(fakeApiUrl + '/profile').respond(401); $httpBackend.expectGET(fakeApiUrl + '/results?page=1').respond(404, {'detail': 'Not Found'}); $httpBackend.flush(); @@ -174,6 +201,7 @@ describe('Refstack controllers', function () { it('should have an function to clear filters and update the view', function () { + $httpBackend.expectGET(fakeApiUrl + '/profile').respond(401); $httpBackend.expectGET(fakeApiUrl + '/results?page=1').respond(fakeResponse); scope.startDate = 'some date'; @@ -190,7 +218,6 @@ describe('Refstack controllers', function () { describe('resultsReportController', function () { var scope, $httpBackend, stateparams; - var fakeApiUrl = 'http://foo.bar/v1'; var fakeResultResponse = {'results': ['test_id_1']}; var fakeCapabilityResponse = { 'platform': {'required': ['compute']}, @@ -211,13 +238,6 @@ describe('Refstack controllers', function () { } }; - beforeEach(function () { - module('refstackApp'); - module(function ($provide) { - $provide.constant('refstackApiUrl', fakeApiUrl); - }); - }); - beforeEach(inject(function (_$httpBackend_, $rootScope, $controller) { $httpBackend = _$httpBackend_; stateparams = {testID: 1234}; @@ -229,6 +249,7 @@ describe('Refstack controllers', function () { it('should make all necessary API requests to get results ' + 'and capabilities', function () { + $httpBackend.expectGET(fakeApiUrl + '/profile').respond(401); $httpBackend.expectGET(fakeApiUrl + '/results/1234').respond(fakeResultResponse); $httpBackend.expectGET(fakeApiUrl + diff --git a/refstack-ui/tests/unit/FilterSpec.js b/refstack-ui/tests/unit/FilterSpec.js index b5939d19..b88a08ec 100644 --- a/refstack-ui/tests/unit/FilterSpec.js +++ b/refstack-ui/tests/unit/FilterSpec.js @@ -2,9 +2,16 @@ describe('Refstack filters', function () { 'use strict'; + var fakeApiUrl = 'http://foo.bar/v1'; + beforeEach(function () { + module(function ($provide) { + $provide.constant('refstackApiUrl', fakeApiUrl); + }); + module('refstackApp'); + }); + describe('Filter: arrayConverter', function () { var $filter; - beforeEach(module('refstackApp')); beforeEach(inject(function (_$filter_) { $filter = _$filter_('arrayConverter'); })); @@ -19,7 +26,6 @@ describe('Refstack filters', function () { describe('Filter: capitalize', function() { var $filter; - beforeEach(module('refstackApp')); beforeEach(inject(function(_$filter_) { $filter = _$filter_('capitalize'); })); diff --git a/refstack/__init__.py b/refstack/__init__.py index e69de29b..c70611c0 100644 --- a/refstack/__init__.py +++ b/refstack/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Refstack package.""" diff --git a/refstack/api/__init__.py b/refstack/api/__init__.py index e69de29b..dbe30ac6 100644 --- a/refstack/api/__init__.py +++ b/refstack/api/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Refstack API package.""" diff --git a/refstack/api/app.py b/refstack/api/app.py index b90977e1..5e6ca1f3 100644 --- a/refstack/api/app.py +++ b/refstack/api/app.py @@ -19,20 +19,33 @@ import json import logging import os +from beaker.middleware import SessionMiddleware from oslo_config import cfg from oslo_log import log from oslo_log import loggers import pecan import webob +from refstack.api import utils as api_utils from refstack.common import validators LOG = log.getLogger(__name__) PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) +UI_OPTS = [ + cfg.StrOpt('ui_url', + default='http://refstack.net', + help='Url of user interface for Refstack. Need for redirects ' + 'after sign in and sign out.' + ), +] API_OPTS = [ + cfg.StrOpt('api_url', + default='http://refstack.net', + help='Url of public Refstack API.' + ), cfg.StrOpt('static_root', default='%(project_root)s/static', help='The directory where your static files can ' @@ -65,7 +78,7 @@ API_OPTS = [ 'contain some details with debug information.' ), cfg.StrOpt('test_results_url', - default='http://refstack.net/#/results/%s', + default='/#/results/%s', help='Template for test result url.' ), cfg.StrOpt('github_api_capabilities_url', @@ -89,6 +102,8 @@ CONF = cfg.CONF opt_group = cfg.OptGroup(name='api', title='Options for the Refstack API') +CONF.register_opts(UI_OPTS) + CONF.register_group(opt_group) CONF.register_opts(API_OPTS, opt_group) @@ -96,9 +111,8 @@ log.register_options(CONF) class JSONErrorHook(pecan.hooks.PecanHook): - """ - A pecan hook that translates webob HTTP errors into a JSON format. - """ + + """A pecan hook that translates webob HTTP errors into a JSON format.""" def __init__(self): """Hook init.""" @@ -106,7 +120,9 @@ class JSONErrorHook(pecan.hooks.PecanHook): def on_error(self, state, exc): """Request error handler.""" - if isinstance(exc, webob.exc.HTTPError): + if isinstance(exc, webob.exc.HTTPRedirection): + return + elif isinstance(exc, webob.exc.HTTPError): status_code = exc.status_int body = {'title': exc.title} elif isinstance(exc, validators.ValidationError): @@ -128,9 +144,8 @@ class JSONErrorHook(pecan.hooks.PecanHook): class CORSHook(pecan.hooks.PecanHook): - """ - A pecan hook that handles Cross-Origin Resource Sharing. - """ + + """A pecan hook that handles Cross-Origin Resource Sharing.""" def __init__(self): """Init the hook by getting the allowed origins.""" @@ -149,6 +164,7 @@ class CORSHook(pecan.hooks.PecanHook): 'GET, OPTIONS, PUT, POST' state.response.headers['Access-Control-Allow-Headers'] = \ 'origin, authorization, accept, content-type' + state.response.headers['Access-Control-Allow-Credentials'] = 'true' def setup_app(config): @@ -187,8 +203,16 @@ def setup_app(config): )] ) + beaker_conf = { + 'session.key': 'refstack', + 'session.type': 'memory', + 'session.timeout': 604800, + 'session.validate_key': api_utils.get_token(), + } + app = SessionMiddleware(app, beaker_conf) + if CONF.api.app_dev_mode: - LOG.debug('\n\n Refstack is served at %s \n\n', - CONF.api.test_results_url.split('/#/')[0]) + LOG.debug('\n\n <<< Refstack UI is available at %s >>>\n\n', + CONF.ui_url) return app diff --git a/refstack/api/constants.py b/refstack/api/constants.py index 9d2f7ff4..c4a22117 100644 --- a/refstack/api/constants.py +++ b/refstack/api/constants.py @@ -12,12 +12,27 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -""" -Constants for Refstack API -""" +"""Constants for Refstack API.""" # Names of input parameters for request START_DATE = 'start_date' END_DATE = 'end_date' CPID = 'cpid' PAGE = 'page' + +# OpenID parameters +OPENID_MODE = 'openid.mode' +OPENID_NS = 'openid.ns' +OPENID_RETURN_TO = 'openid.return_to' +OPENID_CLAIMED_ID = 'openid.claimed_id' +OPENID_IDENTITY = 'openid.identity' +OPENID_REALM = 'openid.realm' +OPENID_NS_SREG = 'openid.ns.sreg' +OPENID_NS_SREG_REQUIRED = 'openid.sreg.required' +OPENID_NS_SREG_EMAIL = 'openid.sreg.email' +OPENID_NS_SREG_FULLNAME = 'openid.sreg.fullname' +OPENID_ERROR = 'openid.error' + +# User session parameters +CSRF_TOKEN = 'csrf_token' +USER_OPENID = 'user_openid' diff --git a/refstack/api/controllers/__init__.py b/refstack/api/controllers/__init__.py index e69de29b..9c943985 100644 --- a/refstack/api/controllers/__init__.py +++ b/refstack/api/controllers/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""API controllers package.""" diff --git a/refstack/api/controllers/auth.py b/refstack/api/controllers/auth.py new file mode 100644 index 00000000..b8ceffdd --- /dev/null +++ b/refstack/api/controllers/auth.py @@ -0,0 +1,170 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Authentication controller.""" +from oslo_config import cfg +from oslo_log import log +import pecan +from pecan import rest +from six.moves.urllib import parse + +from refstack.api import constants as const +from refstack.api import utils as api_utils +from refstack import db + + +LOG = log.getLogger(__name__) + +OPENID_OPTS = [ + cfg.StrOpt('openstack_openid_endpoint', + default='https://openstackid.org/accounts/openid2', + help='OpenStackID Auth Server URI.' + ), + cfg.StrOpt('openid_mode', + default='checkid_setup', + help='Interaction mode. Specifies whether Openstack Id ' + 'IdP may interact with the user to determine the ' + 'outcome of the request.' + ), + cfg.StrOpt('openid_ns', + default='http://specs.openid.net/auth/2.0', + help='Protocol version. Value identifying the OpenID ' + 'protocol version being used. This value should ' + 'be "http://specs.openid.net/auth/2.0".' + ), + cfg.StrOpt('openid_return_to', + default='/v1/auth/signin_return', + help='Return endpoint in Refstack\'s API. Value indicating ' + 'the endpoint where the user should be returned to after ' + 'signing in. Openstack Id Idp only supports HTTPS ' + 'address types.' + ), + cfg.StrOpt('openid_claimed_id', + default='http://specs.openid.net/auth/2.0/identifier_select', + help='Claimed identifier. This value must be set to ' + '"http://specs.openid.net/auth/2.0/identifier_select". ' + 'or to user claimed identity (user local identifier ' + 'or user owned identity [ex: custom html hosted on a ' + 'owned domain set to html discover]).' + ), + cfg.StrOpt('openid_identity', + default='http://specs.openid.net/auth/2.0/identifier_select', + help='Alternate identifier. This value must be set to ' + 'http://specs.openid.net/auth/2.0/identifier_select.' + ), + cfg.StrOpt('openid_ns_sreg', + default='http://openid.net/extensions/sreg/1.1', + help='Indicates request for user attribute information. ' + 'This value must be set to ' + '"http://openid.net/extensions/sreg/1.1".' + ), + cfg.StrOpt('openid_sreg_required', + default='email,fullname', + help='Comma-separated list of field names which, ' + 'if absent from the response, will prevent the ' + 'Consumer from completing the registration without ' + 'End User interation. The field names are those that ' + 'are specified in the Response Format, with the ' + '"openid.sreg." prefix removed. Valid values include: ' + '"country", "email", "firstname", "language", "lastname"' + ) +] + +CONF = cfg.CONF +opt_group = cfg.OptGroup(name='osid', + title='Options for the Refstack OpenID 2.0 through ' + 'Openstack Authentication Server') +CONF.register_group(opt_group) +CONF.register_opts(OPENID_OPTS, opt_group) + + +class AuthController(rest.RestController): + + """Controller provides user authentication in OpenID 2.0 IdP.""" + + _custom_actions = { + "signin": ["GET"], + "signin_return": ["GET"], + "signout": ["GET"] + } + + @pecan.expose() + def signin(self): + """Handle signin request.""" + session = api_utils.get_user_session() + if api_utils.is_authenticated(): + pecan.redirect(CONF.ui_url) + else: + api_utils.delete_params_from_user_session([const.USER_OPENID]) + + csrf_token = api_utils.get_token() + session[const.CSRF_TOKEN] = csrf_token + session.save() + return_endpoint = parse.urljoin(CONF.api.api_url, + CONF.osid.openid_return_to) + return_to = api_utils.set_query_params(return_endpoint, + {const.CSRF_TOKEN: csrf_token}) + + params = { + const.OPENID_MODE: CONF.osid.openid_mode, + const.OPENID_NS: CONF.osid.openid_ns, + const.OPENID_RETURN_TO: return_to, + const.OPENID_CLAIMED_ID: CONF.osid.openid_claimed_id, + const.OPENID_IDENTITY: CONF.osid.openid_identity, + const.OPENID_REALM: CONF.api.api_url, + const.OPENID_NS_SREG: CONF.osid.openid_ns_sreg, + const.OPENID_NS_SREG_REQUIRED: CONF.osid.openid_sreg_required, + } + url = CONF.osid.openstack_openid_endpoint + url = api_utils.set_query_params(url, params) + pecan.redirect(location=url) + + @pecan.expose() + def signin_return(self): + """Handle returned request from OpenID 2.0 IdP.""" + session = api_utils.get_user_session() + if pecan.request.GET.get(const.OPENID_ERROR): + api_utils.delete_params_from_user_session([const.CSRF_TOKEN]) + pecan.abort(401, pecan.request.GET.get(const.OPENID_ERROR)) + + if pecan.request.GET.get(const.OPENID_MODE) == 'cancel': + api_utils.delete_params_from_user_session([const.CSRF_TOKEN]) + pecan.abort(401, 'Authentication canceled.') + + session_token = session.get(const.CSRF_TOKEN) + request_token = pecan.request.GET.get(const.CSRF_TOKEN) + if request_token != session_token: + api_utils.delete_params_from_user_session([const.CSRF_TOKEN]) + pecan.abort(401, 'Authentication is failed. Try again.') + + api_utils.verify_openid_request(pecan.request) + user_info = { + 'openid': pecan.request.GET.get(const.OPENID_CLAIMED_ID), + 'email': pecan.request.GET.get(const.OPENID_NS_SREG_EMAIL), + 'fullname': pecan.request.GET.get(const.OPENID_NS_SREG_FULLNAME) + } + user = db.user_update_or_create(user_info) + + api_utils.delete_params_from_user_session([const.CSRF_TOKEN]) + session[const.USER_OPENID] = user.openid + session.save() + + pecan.redirect(CONF.ui_url) + + @pecan.expose() + def signout(self): + """Handle signout request.""" + if api_utils.is_authenticated(): + api_utils.delete_params_from_user_session([const.USER_OPENID]) + pecan.redirect(CONF.ui_url) diff --git a/refstack/api/controllers/root.py b/refstack/api/controllers/root.py index 9741b84b..86a81c18 100644 --- a/refstack/api/controllers/root.py +++ b/refstack/api/controllers/root.py @@ -32,4 +32,9 @@ class RootController(object): if CONF.api.app_dev_mode: @expose(generic=True, template='index.html') def index(self): + """Return index.html in development mode. + + It allows to run both API and UI with pecan serve. + Template path should point into UI app folder + """ return dict() diff --git a/refstack/api/controllers/user.py b/refstack/api/controllers/user.py new file mode 100644 index 00000000..058d60f0 --- /dev/null +++ b/refstack/api/controllers/user.py @@ -0,0 +1,39 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""User profile controller.""" +import pecan +from pecan import rest +from pecan.secure import secure + +from refstack.api import constants as const +from refstack.api import utils as api_utils +from refstack import db + + +class ProfileController(rest.RestController): + + """Controller provides user information in OpenID 2.0 IdP.""" + + @secure(api_utils.is_authenticated) + @pecan.expose('json') + def get(self): + """Handle get request on user info.""" + session = api_utils.get_user_session() + user = db.user_get(session.get(const.USER_OPENID)) + return { + "openid": user.openid, + "email": user.email, + "fullname": user.fullname + } diff --git a/refstack/api/controllers/v1.py b/refstack/api/controllers/v1.py index 9a7a4123..a5c00c40 100644 --- a/refstack/api/controllers/v1.py +++ b/refstack/api/controllers/v1.py @@ -24,10 +24,13 @@ from pecan import rest import re import requests import requests_cache +from six.moves.urllib import parse from refstack import db from refstack.api import constants as const from refstack.api import utils as api_utils +from refstack.api.controllers import auth +from refstack.api.controllers import user from refstack.common import validators LOG = log.getLogger(__name__) @@ -55,7 +58,8 @@ requests_cache.install_cache(cache_name='github_cache', class BaseRestControllerWithValidation(rest.RestController): - """ + """Rest controller with validation. + Controller provides validation for POSTed data exposed endpoints: POST base_url/ @@ -66,22 +70,24 @@ class BaseRestControllerWithValidation(rest.RestController): __validator__ = None def __init__(self): # pragma: no cover + """Init.""" if self.__validator__: self.validator = self.__validator__() else: raise ValueError("__validator__ is not defined") def get_item(self, item_id): # pragma: no cover - """Handler for getting item""" + """Handler for getting item.""" raise NotImplementedError def store_item(self, item_in_json): # pragma: no cover - """Handler for storing item. Should return new item id""" + """Handler for storing item. Should return new item id.""" raise NotImplementedError @pecan.expose('json') def get_one(self, arg): """Return test results in JSON format. + :param arg: item ID in uuid4 format or action """ if self.validator.assert_id(arg): @@ -110,7 +116,7 @@ class ResultsController(BaseRestControllerWithValidation): __validator__ = validators.TestResultValidator def get_item(self, item_id): - """Handler for getting item""" + """Handler for getting item.""" test_info = db.get_test(item_id) if not test_info: pecan.abort(404) @@ -122,7 +128,7 @@ class ResultsController(BaseRestControllerWithValidation): "results": test_name_list} def store_item(self, item_in_json): - """Handler for storing item. Should return new item id""" + """Handler for storing item. Should return new item id.""" item = item_in_json.copy() if pecan.request.headers.get('X-Public-Key'): if 'metadata' not in item: @@ -132,21 +138,21 @@ class ResultsController(BaseRestControllerWithValidation): test_id = db.store_results(item) LOG.debug(item) return {'test_id': test_id, - 'url': CONF.api.test_results_url % test_id} + 'url': parse.urljoin(CONF.ui_url, + CONF.api.test_results_url) % test_id} @pecan.expose('json') def get(self): - """ - Get information of all uploaded test results in descending - chronological order. - Make it possible to specify some input parameters - for filtering. - For example: - /v1/results?page=&cpid=1234. - By default, page is set to page number 1, - if the page parameter is not specified. - """ + """Get information of all uploaded test results. + Get information of all uploaded test results in descending + chronological order. Make it possible to specify some + input parameters for filtering. + For example: + /v1/results?page=&cpid=1234. + By default, page is set to page number 1, + if the page parameter is not specified. + """ expected_input_params = [ const.START_DATE, const.END_DATE, @@ -192,8 +198,11 @@ class ResultsController(BaseRestControllerWithValidation): class CapabilitiesController(rest.RestController): - """/v1/capabilities handler. This acts as a proxy for retrieving - capability files from the openstack/defcore Github repository.""" + """/v1/capabilities handler. + + This acts as a proxy for retrieving capability files + from the openstack/defcore Github repository. + """ @pecan.expose('json') def get(self): @@ -248,3 +257,5 @@ class V1Controller(object): results = ResultsController() capabilities = CapabilitiesController() + auth = auth.AuthController() + profile = user.ProfileController() diff --git a/refstack/api/utils.py b/refstack/api/utils.py index 16a16c7b..7307dbd4 100644 --- a/refstack/api/utils.py +++ b/refstack/api/utils.py @@ -15,12 +15,18 @@ """Refstack API's utils.""" import copy +import random +import requests +import string from oslo_config import cfg from oslo_log import log from oslo_utils import timeutils import pecan +import six +from six.moves.urllib import parse +from refstack import db from refstack.api import constants as const LOG = log.getLogger(__name__) @@ -28,14 +34,17 @@ CONF = cfg.CONF class ParseInputsError(Exception): + + """Raise if input params are invalid.""" + pass def _get_input_params_from_request(expected_params): """Get input parameters from request. - :param expecred_params: (array) Expected input - params specified in constants. + :param expecred_params: (array) Expected input + params specified in constants. """ filters = {} for param in expected_params: @@ -53,9 +62,8 @@ def _get_input_params_from_request(expected_params): def parse_input_params(expected_input_params): """Parse input parameters from request. - :param expecred_params: (array) Expected input - params specified in constants. - + :param expecred_params: (array) Expected input + params specified in constants. """ raw_filters = _get_input_params_from_request(expected_input_params) filters = copy.deepcopy(raw_filters) @@ -83,8 +91,9 @@ def parse_input_params(expected_input_params): def _calculate_pages_number(per_page, records_count): """Return pages number. - :param per_page: (int) results number fot one page. - :param records_count: (int) total records count. + + :param per_page: (int) results number fot one page. + :param records_count: (int) total records count. """ quotient, remainder = divmod(records_count, per_page) if remainder > 0: @@ -93,8 +102,9 @@ def _calculate_pages_number(per_page, records_count): def get_page_number(records_count): - """Get page number from request - :param records_count: (int) total records count. + """Get page number from request. + + :param records_count: (int) total records count. """ page_number = pecan.request.GET.get(const.PAGE) per_page = CONF.api.results_per_page @@ -121,3 +131,80 @@ def get_page_number(records_count): 'is greater than the total number of pages.') return (page_number, total_pages) + + +def set_query_params(url, params): + """Set params in given query.""" + url_parts = parse.urlparse(url) + url = parse.urlunparse(( + url_parts.scheme, + url_parts.netloc, + url_parts.path, + url_parts.params, + parse.urlencode(params), + url_parts.fragment)) + return url + + +def get_token(length=30): + """Get random token.""" + return ''.join(random.choice(string.ascii_lowercase) + for i in range(length)) + + +def delete_params_from_user_session(params): + """Delete params from user session.""" + session = get_user_session() + for param in params: + if session.get(param): + del session[param] + session.save() + + +def get_user_session(): + """Return user session.""" + return pecan.request.environ['beaker.session'] + + +def is_authenticated(): + """Return True if user is authenticated.""" + session = get_user_session() + if session.get(const.USER_OPENID): + try: + if db.user_get(session.get(const.USER_OPENID)): + return True + except db.UserNotFound: + pass + return False + + +def verify_openid_request(request): + """Verify OpenID returned request in OpenID.""" + verify_params = dict(request.params.copy()) + verify_params["openid.mode"] = "check_authentication" + + verify_response = requests.post( + CONF.osid.openstack_openid_endpoint, data=verify_params, + verify=not CONF.api.app_dev_mode + ) + verify_data_tokens = verify_response.content.split() + verify_dict = dict((token.split(":")[0], token.split(":")[1]) + for token in verify_data_tokens) + + if (verify_response.status_code / 100 != 2 + or verify_dict['is_valid'] != 'true'): + pecan.abort(401, 'Authentication is failed. Try again.') + + # Is the data we've received within our required parameters? + required_parameters = { + const.OPENID_NS_SREG_EMAIL: 'Please permit access to ' + 'your email address.', + const.OPENID_NS_SREG_FULLNAME: 'Please permit access to ' + 'your name.', + } + + for name, error in six.iteritems(required_parameters): + if name not in verify_params or not verify_params[name]: + pecan.abort(401, 'Authentication is failed. %s' % error) + + return True diff --git a/refstack/common/__init__.py b/refstack/common/__init__.py index e69de29b..65b8b7c3 100644 --- a/refstack/common/__init__.py +++ b/refstack/common/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Refstack common package.""" diff --git a/refstack/common/validators.py b/refstack/common/validators.py index ad775952..bdb2ce87 100644 --- a/refstack/common/validators.py +++ b/refstack/common/validators.py @@ -13,8 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -""" Validators module -""" +"""Validators module.""" import binascii import uuid @@ -30,7 +29,10 @@ ext_format_checker = jsonschema.FormatChecker() class ValidationError(Exception): + """Raise if request doesn't pass trough validation process.""" + def __init__(self, title, exc=None): + """Init.""" super(ValidationError, self).__init__(title) self.exc = exc self.title = title @@ -40,14 +42,16 @@ class ValidationError(Exception): if self.exc else self.title def __repr__(self): + """Repr method.""" return self.details def __str__(self): + """Str method.""" return self.__repr__() def is_uuid(inst): - """ Check that inst is a uuid_hex string. """ + """Check that inst is a uuid_hex string.""" try: uuid.UUID(hex=inst) except (TypeError, ValueError): @@ -59,20 +63,20 @@ def is_uuid(inst): format='uuid_hex', raises=(TypeError, ValueError)) def checker_uuid(inst): - """Checker 'uuid_hex' format for jsonschema validator""" + """Checker 'uuid_hex' format for jsonschema validator.""" return is_uuid(inst) class Validator(object): - """Base class for validators""" + """Base class for validators.""" + def __init__(self): + """Init.""" self.schema = {} # pragma: no cover def validate(self, request): - """ - :param json_data: data for validation - """ + """Validate request.""" try: body = json.loads(request.body) except (ValueError, TypeError) as e: @@ -89,7 +93,7 @@ class TestResultValidator(Validator): """Validator for incoming test results.""" def __init__(self): - + """Init.""" self.schema = { 'type': 'object', 'properties': { @@ -122,6 +126,7 @@ class TestResultValidator(Validator): ) def validate(self, request): + """Validate uploaded test results.""" super(TestResultValidator, self).validate(request) if request.headers.get('X-Signature') or \ request.headers.get('X-Public-Key'): @@ -142,5 +147,5 @@ class TestResultValidator(Validator): @staticmethod def assert_id(_id): - """ Check that _id is a valid uuid_hex string. """ + """Check that _id is a valid uuid_hex string.""" return is_uuid(_id) diff --git a/refstack/db/__init__.py b/refstack/db/__init__.py index 0ea4f423..9b74e2aa 100644 --- a/refstack/db/__init__.py +++ b/refstack/db/__init__.py @@ -12,8 +12,6 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -""" -DB abstraction for Refstack -""" +"""DB abstraction for Refstack.""" from refstack.db.api import * # noqa diff --git a/refstack/db/api.py b/refstack/db/api.py index 9e6d8ed1..16670842 100644 --- a/refstack/db/api.py +++ b/refstack/db/api.py @@ -18,7 +18,6 @@ Functions in this module are imported into the refstack.db namespace. Call these functions from refstack.db namespace, not the refstack.db.api namespace. - """ from oslo_config import cfg from oslo_db import api as db_api @@ -37,8 +36,9 @@ _BACKEND_MAPPING = {'sqlalchemy': 'refstack.db.sqlalchemy.api'} IMPL = db_api.DBAPI.from_config(cfg.CONF, backend_mapping=_BACKEND_MAPPING, lazy=True) +UserNotFound = IMPL.UserNotFound + -################### def store_results(results): """Storing results into database. @@ -66,9 +66,9 @@ def get_test_results(test_id): def get_test_records(page_number, per_page, filters): """Get page with applied filters for uploaded test records. - :param page_number: The number of page. - :param per_page: The number of results for one page. - :param filters: (Dict) Filters that will be applied for records. + :param page_number: The number of page. + :param per_page: The number of results for one page. + :param filters: (Dict) Filters that will be applied for records. """ return IMPL.get_test_records(page_number, per_page, filters) @@ -76,6 +76,22 @@ def get_test_records(page_number, per_page, filters): def get_test_records_count(filters): """Get total pages number with applied filters for uploaded test records. - :param filters: (Dict) Filters that will be applied for records. + :param filters: (Dict) Filters that will be applied for records. """ return IMPL.get_test_records_count(filters) + + +def user_get(user_openid): + """Get user info. + + :param user_openid: User openid + """ + return IMPL.user_get(user_openid) + + +def user_update_or_create(user_info): + """Create user DB record if it exists, otherwise record will be updated. + + :param user_info: User record + """ + return IMPL.user_update_or_create(user_info) diff --git a/refstack/db/migrations/__init__.py b/refstack/db/migrations/__init__.py index e69de29b..04a92d5b 100644 --- a/refstack/db/migrations/__init__.py +++ b/refstack/db/migrations/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Migrations.""" diff --git a/refstack/db/migrations/alembic/__init__.py b/refstack/db/migrations/alembic/__init__.py index e69de29b..6384a678 100644 --- a/refstack/db/migrations/alembic/__init__.py +++ b/refstack/db/migrations/alembic/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Alembic backend for migrations.""" diff --git a/refstack/db/migrations/alembic/env.py b/refstack/db/migrations/alembic/env.py index 85c4ef59..1b4427ea 100755 --- a/refstack/db/migrations/alembic/env.py +++ b/refstack/db/migrations/alembic/env.py @@ -14,6 +14,8 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +"""Alembic environment script.""" + from __future__ import with_statement from alembic import context @@ -23,12 +25,11 @@ from refstack.db.sqlalchemy import models as db_models def run_migrations_online(): - """Run migrations in 'online' mode. In this scenario we need to create an Engine - and associate a connection with the context.""" - + and associate a connection with the context. + """ engine = db_api.get_engine() connection = engine.connect() target_metadata = db_models.RefStackBase.metadata diff --git a/refstack/db/migrations/alembic/migration.py b/refstack/db/migrations/alembic/migration.py index 8e763fe1..bbe64db2 100644 --- a/refstack/db/migrations/alembic/migration.py +++ b/refstack/db/migrations/alembic/migration.py @@ -12,9 +12,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -""" -Implementation of Alembic commands. -""" +"""Implementation of Alembic commands.""" import os import alembic diff --git a/refstack/db/migrations/alembic/versions/2f178b0bf762_create_user_table.py b/refstack/db/migrations/alembic/versions/2f178b0bf762_create_user_table.py new file mode 100644 index 00000000..c177aacb --- /dev/null +++ b/refstack/db/migrations/alembic/versions/2f178b0bf762_create_user_table.py @@ -0,0 +1,38 @@ +"""Create user table. + +Revision ID: 2f178b0bf762 +Revises: 42278d6179b9 +Create Date: 2015-05-12 12:15:43.810938 + +""" + +# revision identifiers, used by Alembic. +revision = '2f178b0bf762' +down_revision = '42278d6179b9' +MYSQL_CHARSET = 'utf8' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + """Upgrade DB.""" + op.create_table( + 'user', + sa.Column('updated_at', sa.DateTime()), + sa.Column('deleted_at', sa.DateTime()), + sa.Column('deleted', sa.Integer, default=0), + sa.Column('_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('openid', sa.String(length=128), + nullable=False, unique=True), + sa.Column('email', sa.String(length=128)), + sa.Column('fullname', sa.String(length=128)), + sa.PrimaryKeyConstraint('_id'), + mysql_charset=MYSQL_CHARSET + ) + + +def downgrade(): + """Downgrade DB.""" + op.drop_table('user') diff --git a/refstack/db/migrations/alembic/versions/42278d6179b9_init.py b/refstack/db/migrations/alembic/versions/42278d6179b9_init.py index 076dfeee..5957e18c 100644 --- a/refstack/db/migrations/alembic/versions/42278d6179b9_init.py +++ b/refstack/db/migrations/alembic/versions/42278d6179b9_init.py @@ -1,4 +1,4 @@ -"""Init +"""Init. Revision ID: 42278d6179b9 Revises: None @@ -16,6 +16,7 @@ import sqlalchemy as sa def upgrade(): + """Upgrade DB.""" op.create_table( 'test', sa.Column('updated_at', sa.DateTime()), @@ -67,6 +68,7 @@ def upgrade(): def downgrade(): + """Downgrade DB.""" op.drop_table('results') op.drop_table('meta') op.drop_table('test') diff --git a/refstack/db/sqlalchemy/__init__.py b/refstack/db/sqlalchemy/__init__.py index e69de29b..70b963e7 100644 --- a/refstack/db/sqlalchemy/__init__.py +++ b/refstack/db/sqlalchemy/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""SQLAlchemy backend.""" diff --git a/refstack/db/sqlalchemy/api.py b/refstack/db/sqlalchemy/api.py index a929e0cb..033c3aab 100644 --- a/refstack/db/sqlalchemy/api.py +++ b/refstack/db/sqlalchemy/api.py @@ -12,9 +12,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -""" -Implementation of SQLAlchemy backend. -""" +"""Implementation of SQLAlchemy backend.""" import sys import uuid @@ -35,6 +33,7 @@ db_options.set_defaults(cfg.CONF) def _create_facade_lazily(): + """Create DB facade lazily.""" global _FACADE if _FACADE is None: _FACADE = db_session.EngineFacade.from_config(CONF) @@ -42,25 +41,24 @@ def _create_facade_lazily(): def get_engine(): + """Get DB engine.""" facade = _create_facade_lazily() return facade.get_engine() def get_session(**kwargs): + """Get DB session.""" facade = _create_facade_lazily() return facade.get_session(**kwargs) def get_backend(): """The backend is this module itself.""" - return sys.modules[__name__] -################### - - def store_results(results): + """Store test results.""" test = models.Test() test_id = str(uuid.uuid4()) test.id = test_id @@ -83,6 +81,7 @@ def store_results(results): def get_test(test_id): + """Get test info.""" session = get_session() test_info = session.query(models.Test).\ filter_by(id=test_id).\ @@ -91,6 +90,7 @@ def get_test(test_id): def get_test_results(test_id): + """Get test results.""" session = get_session() results = session.query(models.TestResults.name).\ filter_by(test_id=test_id).\ @@ -99,6 +99,7 @@ def get_test_results(test_id): def _apply_filters_for_query(query, filters): + """Apply filters for DB query.""" start_date = filters.get(api_const.START_DATE) if start_date: query = query.filter(models.Test.created_at >= start_date) @@ -115,6 +116,7 @@ def _apply_filters_for_query(query, filters): def get_test_records(page, per_page, filters): + """Get page with list of test records.""" session = get_session() query = session.query(models.Test.id, models.Test.created_at, @@ -128,8 +130,39 @@ def get_test_records(page, per_page, filters): def get_test_records_count(filters): + """Get total test records count.""" session = get_session() query = session.query(models.Test.id) records_count = _apply_filters_for_query(query, filters).count() return records_count + + +class UserNotFound(Exception): + + """Raise if user not found.""" + + pass + + +def user_get(user_openid): + """Get user info by openid.""" + session = get_session() + user = session.query(models.User).filter_by(openid=user_openid).first() + if user is None: + raise UserNotFound('User with OpenID %s not found' % user_openid) + return user + + +def user_update_or_create(user_info): + """Create user DB record if it exists, otherwise record will be updated.""" + try: + user = user_get(user_info['openid']) + except UserNotFound: + user = models.User() + + session = get_session() + with session.begin(): + user.update(user_info) + user.save(session=session) + return user diff --git a/refstack/db/sqlalchemy/models.py b/refstack/db/sqlalchemy/models.py index d91a11b5..b271312e 100644 --- a/refstack/db/sqlalchemy/models.py +++ b/refstack/db/sqlalchemy/models.py @@ -13,9 +13,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -""" -SQLAlchemy models for Refstack data. -""" +"""SQLAlchemy models for Refstack data.""" from oslo_config import cfg from oslo_db.sqlalchemy import models @@ -82,3 +80,15 @@ class TestMeta(BASE, RefStackBase): index=True, nullable=False, unique=False) meta_key = sa.Column(sa.String(64), index=True, nullable=False) value = sa.Column(sa.Text()) + + +class User(BASE, RefStackBase): + + """User information.""" + + __tablename__ = 'user' + _id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) + openid = sa.Column(sa.String(128), nullable=False, unique=True, + index=True) + email = sa.Column(sa.String(128)) + fullname = sa.Column(sa.String(128)) diff --git a/refstack/db/utils.py b/refstack/db/utils.py index 40d805be..3ab5402c 100644 --- a/refstack/db/utils.py +++ b/refstack/db/utils.py @@ -22,14 +22,17 @@ LOG = log.getLogger(__name__) class PluggableBackend(object): + """A pluggable backend loaded lazily based on some value.""" def __init__(self, pivot, **backends): + """Init.""" self.__backends = backends self.__pivot = pivot self.__backend = None def __get_backend(self): + """Get backend.""" if not self.__backend: backend_name = CONF[self.__pivot] if backend_name not in self.__backends: # pragma: no cover @@ -48,5 +51,6 @@ class PluggableBackend(object): return self.__backend def __getattr__(self, key): + """Proxy interface to backend.""" backend = self.__get_backend() return getattr(backend, key) diff --git a/refstack/opts.py b/refstack/opts.py index bb0110a0..de27ee07 100644 --- a/refstack/opts.py +++ b/refstack/opts.py @@ -12,36 +12,43 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -""" - Function list_opts intended for oslo-config-generator. It tool used for - generate config file with help info and default values for options defined - anywhere in application. - All new options must be imported here and must be returned from - list_opts function as list that contain tuple. - Use itertools.chain if config section contain more than one imported module - with options. For example: +"""Function list_opts intended for oslo-config-generator. - ... - def list_opts(): - return [ - ('DEFAULT', refstack.db.api.db_opts), - ('api', - itertools.chain(refstack.api.first.module.opts, - refstack.api.second.modulei.opts,)), - ] - ... +this tool used for generate config file with help info and default values +for options defined anywhere in application. +All new options must be imported here and must be returned from +list_opts function as list that contain tuple. +Use itertools.chain if config section contain more than one imported module +with options. For example: + +... +def list_opts(): + return [ + ('DEFAULT', refstack.db.api.db_opts), + ('api', + itertools.chain(refstack.api.first.module.opts, + refstack.api.second.modulei.opts,)), + ] +... """ import itertools import refstack.api.app import refstack.api.controllers.v1 +import refstack.api.controllers.auth import refstack.db.api def list_opts(): + """List oslo config options. + + Keep a list in alphabetical order + """ return [ - # Keep a list in alphabetical order - ('DEFAULT', refstack.db.api.db_opts), + # + ('DEFAULT', itertools.chain(refstack.api.app.UI_OPTS, + refstack.db.api.db_opts)), ('api', itertools.chain(refstack.api.app.API_OPTS, refstack.api.controllers.v1.CTRLS_OPTS)), + ('osid', refstack.api.controllers.auth.OPENID_OPTS), ] diff --git a/refstack/tests/__init__.py b/refstack/tests/__init__.py index e69de29b..ab867d17 100644 --- a/refstack/tests/__init__.py +++ b/refstack/tests/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Refstack tests.""" diff --git a/refstack/tests/api/__init__.py b/refstack/tests/api/__init__.py index f4c189e7..8614f922 100644 --- a/refstack/tests/api/__init__.py +++ b/refstack/tests/api/__init__.py @@ -74,7 +74,7 @@ class FunctionalTest(base.BaseTestCase): self.app.reset() def drop_all_tables_and_constraints(self): - """Drop tables and cyclical constraints between tables""" + """Drop tables and cyclical constraints between tables.""" engine = create_engine(self.connection) conn = engine.connect() trans = conn.begin() diff --git a/refstack/tests/unit/__init__.py b/refstack/tests/unit/__init__.py index e69de29b..08f55b0a 100644 --- a/refstack/tests/unit/__init__.py +++ b/refstack/tests/unit/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2015 Mirantis, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""Refstack unittests.""" diff --git a/refstack/tests/unit/test_api.py b/refstack/tests/unit/test_api.py index 98a5d043..3956c47f 100644 --- a/refstack/tests/unit/test_api.py +++ b/refstack/tests/unit/test_api.py @@ -23,10 +23,14 @@ import mock from oslo_config import fixture as config_fixture from oslotest import base import requests +from six.moves.urllib import parse +import webob.exc from refstack.api import constants as const from refstack.api import utils as api_utils +from refstack.api.controllers import auth from refstack.api.controllers import v1 +from refstack.api.controllers import user def safe_json_dump(content): @@ -62,10 +66,12 @@ class ResultsControllerTestCase(base.BaseTestCase): self.controller = v1.ResultsController() self.config_fixture = config_fixture.Config() self.CONF = self.useFixture(self.config_fixture).conf - self.test_results_url = 'host?%s' + self.test_results_url = '/#/results/%s' + self.ui_url = 'host.org' self.CONF.set_override('test_results_url', self.test_results_url, 'api') + self.CONF.set_override('ui_url', self.ui_url) @mock.patch('refstack.db.get_test') @mock.patch('refstack.db.get_test_results') @@ -101,9 +107,12 @@ class ResultsControllerTestCase(base.BaseTestCase): mock_request.headers = {} mock_store_results.return_value = 'fake_test_id' result = self.controller.post() - self.assertEqual(result, - {'test_id': 'fake_test_id', - 'url': self.test_results_url % 'fake_test_id'}) + self.assertEqual( + result, + {'test_id': 'fake_test_id', + 'url': parse.urljoin(self.ui_url, + self.test_results_url) % 'fake_test_id'} + ) self.assertEqual(mock_response.status, 201) mock_store_results.assert_called_once_with({'answer': 42}) @@ -366,3 +375,152 @@ class BaseRestControllerWithValidationTestCase(base.BaseTestCase): self.validator.assert_id = mock.Mock(return_value=False) self.controller.get_one('fake_arg') mock_abort.assert_called_once_with(404) + + +class ProfileControllerTestCase(base.BaseTestCase): + + def setUp(self): + super(ProfileControllerTestCase, self).setUp() + self.controller = user.ProfileController() + + @mock.patch('refstack.db.user_get', + return_value=mock.Mock(openid='foo@bar.org', + email='foo@bar.org', + fullname='Dobby')) + @mock.patch('refstack.api.utils.get_user_session', + return_value={const.USER_OPENID: 'foo@bar.org'}) + @mock.patch('refstack.api.utils.is_authenticated', return_value=True) + def test_get(self, mock_is_authenticated, mock_get_user_session, + mock_user_get): + actual_result = self.controller.get() + self.assertEqual({'openid': 'foo@bar.org', + 'email': 'foo@bar.org', + 'fullname': 'Dobby'}, actual_result) + + +class AuthControllerTestCase(base.BaseTestCase): + + def setUp(self): + super(AuthControllerTestCase, self).setUp() + self.controller = auth.AuthController() + self.config_fixture = config_fixture.Config() + self.CONF = self.useFixture(self.config_fixture).conf + self.CONF.set_override('app_dev_mode', True, 'api') + self.CONF.set_override('ui_url', '127.0.0.1') + + @mock.patch('refstack.api.utils.get_user_session') + @mock.patch('refstack.api.utils.is_authenticated', return_value=True) + @mock.patch('pecan.redirect', side_effect=webob.exc.HTTPRedirection) + def test_signed_signin(self, mock_redirect, mock_is_authenticated, + mock_get_user_session): + mock_session = mock.MagicMock(**{const.USER_OPENID: 'foo@bar.org'}) + mock_get_user_session.return_value = mock_session + self.assertRaises(webob.exc.HTTPRedirection, self.controller.signin) + mock_redirect.assert_called_with('127.0.0.1') + + @mock.patch('refstack.api.utils.get_user_session') + @mock.patch('refstack.api.utils.is_authenticated', return_value=False) + @mock.patch('pecan.redirect', side_effect=webob.exc.HTTPRedirection) + def test_unsigned_signin(self, mock_redirect, mock_is_authenticated, + mock_get_user_session): + mock_session = mock.MagicMock(**{const.USER_OPENID: 'foo@bar.org'}) + mock_get_user_session.return_value = mock_session + self.assertRaises(webob.exc.HTTPRedirection, self.controller.signin) + self.assertIn(self.CONF.osid.openstack_openid_endpoint, + mock_redirect.call_args[1]['location']) + + @mock.patch('socket.gethostbyname', return_value='1.1.1.1') + @mock.patch('pecan.request') + @mock.patch('refstack.api.utils.get_user_session') + @mock.patch('pecan.abort', side_effect=webob.exc.HTTPError) + def test_signin_return_failed(self, mock_abort, mock_get_user_session, + mock_request, mock_socket): + mock_session = mock.MagicMock(**{const.USER_OPENID: 'foo@bar.org', + const.CSRF_TOKEN: '42'}) + mock_get_user_session.return_value = mock_session + mock_request.remote_addr = '1.1.1.2' + + mock_request.GET = { + const.OPENID_ERROR: 'foo is not bar!!!' + } + mock_request.environ['beaker.session'] = { + const.CSRF_TOKEN: 42 + } + self.assertRaises(webob.exc.HTTPError, self.controller.signin_return) + mock_abort.assert_called_once_with( + 401, mock_request.GET[const.OPENID_ERROR]) + self.assertNotIn(const.CSRF_TOKEN, + mock_request.environ['beaker.session']) + + mock_abort.reset_mock() + mock_request.environ['beaker.session'] = { + const.CSRF_TOKEN: 42 + } + mock_request.GET = { + const.OPENID_MODE: 'cancel' + } + self.assertRaises(webob.exc.HTTPError, self.controller.signin_return) + mock_abort.assert_called_once_with( + 401, 'Authentication canceled.') + self.assertNotIn(const.CSRF_TOKEN, + mock_request.environ['beaker.session']) + + mock_abort.reset_mock() + mock_request.environ['beaker.session'] = { + const.CSRF_TOKEN: 42 + } + mock_request.GET = {} + self.assertRaises(webob.exc.HTTPError, self.controller.signin_return) + mock_abort.assert_called_once_with( + 401, 'Authentication is failed. Try again.') + self.assertNotIn(const.CSRF_TOKEN, + mock_request.environ['beaker.session']) + + mock_abort.reset_mock() + mock_request.environ['beaker.session'] = { + const.CSRF_TOKEN: 42 + } + mock_request.GET = {const.CSRF_TOKEN: '24'} + mock_request.remote_addr = '1.1.1.1' + self.assertRaises(webob.exc.HTTPError, self.controller.signin_return) + mock_abort.assert_called_once_with( + 401, 'Authentication is failed. Try again.') + self.assertNotIn(const.CSRF_TOKEN, + mock_request.environ['beaker.session']) + + @mock.patch('refstack.api.utils.verify_openid_request', return_value=True) + @mock.patch('refstack.db.user_update_or_create') + @mock.patch('pecan.request') + @mock.patch('refstack.api.utils.get_user_session') + @mock.patch('pecan.redirect', side_effect=webob.exc.HTTPRedirection) + def test_signin_return_success(self, mock_redirect, mock_get_user_session, + mock_request, mock_user, mock_verify): + mock_session = mock.MagicMock(**{const.USER_OPENID: 'foo@bar.org', + const.CSRF_TOKEN: 42}) + mock_session.get = mock.Mock(return_value=42) + mock_get_user_session.return_value = mock_session + + mock_request.GET = { + const.OPENID_CLAIMED_ID: 'foo@bar.org', + const.OPENID_NS_SREG_EMAIL: 'foo@bar.org', + const.OPENID_NS_SREG_FULLNAME: 'foo', + const.CSRF_TOKEN: 42 + } + mock_request.environ['beaker.session'] = { + const.CSRF_TOKEN: 42 + } + self.assertRaises(webob.exc.HTTPRedirection, + self.controller.signin_return) + + @mock.patch('pecan.request') + @mock.patch('refstack.api.utils.is_authenticated', return_value=True) + @mock.patch('pecan.redirect', side_effect=webob.exc.HTTPRedirection) + def test_signout(self, mock_redirect, mock_is_authenticated, + mock_request): + mock_request.environ['beaker.session'] = { + const.CSRF_TOKEN: 42 + } + self.assertRaises(webob.exc.HTTPRedirection, self.controller.signout) + mock_redirect.assert_called_with('127.0.0.1') + self.assertNotIn(const.CSRF_TOKEN, + mock_request.environ['beaker.session']) diff --git a/refstack/tests/unit/test_api_utils.py b/refstack/tests/unit/test_api_utils.py index 35151cb4..b326f732 100644 --- a/refstack/tests/unit/test_api_utils.py +++ b/refstack/tests/unit/test_api_utils.py @@ -19,9 +19,11 @@ import mock from oslo_config import fixture as config_fixture from oslo_utils import timeutils from oslotest import base +from six.moves.urllib import parse from refstack.api import constants as const from refstack.api import utils as api_utils +from refstack import db class APIUtilsTestCase(base.BaseTestCase): @@ -255,3 +257,75 @@ class APIUtilsTestCase(base.BaseTestCase): self.assertEqual(page_number, 2) self.assertEqual(total_pages, total_records / per_page) + + def test_set_query_params(self): + url = 'http://e.io/path#fragment' + new_url = api_utils.set_query_params(url, {'foo': 'bar', '?': 42}) + self.assertEqual(parse.parse_qs(parse.urlparse(new_url)[4]), + {'foo': ['bar'], '?': ['42']}) + + def test_get_token(self): + token = api_utils.get_token(42) + self.assertRegexpMatches(token, "[a-z]{42}") + + @mock.patch.object(api_utils, 'get_user_session') + def test_delete_params_from_user_session(self, mock_get_user_session): + mock_session = mock.MagicMock(**{'foo': 'bar', 'answer': 42}) + mock_get_user_session.return_value = mock_session + api_utils.delete_params_from_user_session(('foo', 'answer')) + self.assertNotIn('foo', mock_session.__dir__) + self.assertNotIn('answer', mock_session.__dir__) + mock_session.save.called_once_with() + + @mock.patch('pecan.request') + def test_get_user_session(self, mock_request): + mock_request.environ = {'beaker.session': 42} + session = api_utils.get_user_session() + self.assertEqual(42, session) + + @mock.patch.object(api_utils, 'get_user_session') + @mock.patch.object(api_utils, 'db') + def test_is_authenticated(self, mock_db, mock_get_user_session): + mock_session = mock.MagicMock(**{const.USER_OPENID: 'foo@bar.com'}) + mock_get_user_session.return_value = mock_session + mock_get_user = mock_db.user_get + mock_get_user.return_value = 'Dobby' + self.assertEqual(True, api_utils.is_authenticated()) + mock_db.user_get.called_once_with(mock_session) + mock_db.UserNotFound = db.UserNotFound + mock_get_user.side_effect = mock_db.UserNotFound + self.assertEqual(False, api_utils.is_authenticated()) + + @mock.patch('requests.post') + @mock.patch('pecan.abort') + def test_verify_openid_request(self, mock_abort, mock_post): + mock_response = mock.Mock() + mock_response.content = ('is_valid:true\n' + 'ns:http://specs.openid.net/auth/2.0\n') + mock_response.status_code = 200 + mock_post.return_value = mock_response + mock_request = mock.Mock() + mock_request.params = { + const.OPENID_NS_SREG_EMAIL: 'foo@bar.org', + const.OPENID_NS_SREG_FULLNAME: 'foo' + } + self.assertEqual(True, api_utils.verify_openid_request(mock_request)) + + mock_response.content = ('is_valid:false\n' + 'ns:http://specs.openid.net/auth/2.0\n') + api_utils.verify_openid_request(mock_request) + mock_abort.assert_called_once_with( + 401, 'Authentication is failed. Try again.' + ) + + mock_abort.reset_mock() + mock_response.content = ('is_valid:true\n' + 'ns:http://specs.openid.net/auth/2.0\n') + mock_request.params = { + const.OPENID_NS_SREG_EMAIL: 'foo@bar.org', + } + api_utils.verify_openid_request(mock_request) + mock_abort.assert_called_once_with( + 401, 'Authentication is failed. ' + 'Please permit access to your name.' + ) diff --git a/refstack/tests/unit/test_app.py b/refstack/tests/unit/test_app.py index eca297a1..70253a14 100644 --- a/refstack/tests/unit/test_app.py +++ b/refstack/tests/unit/test_app.py @@ -90,6 +90,15 @@ class JSONErrorHookTestCase(base.BaseTestCase): 'detail': str(exc)} ) + @mock.patch.object(webob, 'Response') + def test_on_http_redirection(self, response): + self.CONF.set_override('app_dev_mode', False, 'api') + + exc = mock.Mock(spec=webob.exc.HTTPRedirection) + hook = app.JSONErrorHook() + result = hook.on_error(mock.Mock(), exc) + self.assertEqual(result, None) + @mock.patch.object(webob, 'Response') def test_on_error_with_other_exceptions(self, response): self.CONF.set_override('app_dev_mode', False, 'api') @@ -180,7 +189,9 @@ class SetupAppTestCase(base.BaseTestCase): @mock.patch.object(app, 'CORSHook') @mock.patch('os.path.join') @mock.patch('pecan.make_app') - def test_setup_app(self, make_app, os_join, + @mock.patch('refstack.api.app.SessionMiddleware') + @mock.patch('refstack.api.utils.get_token', return_value='42') + def test_setup_app(self, get_token, session_middleware, make_app, os_join, json_error_hook, cors_hook, pecan_hooks): self.CONF.set_override('app_dev_mode', @@ -201,10 +212,11 @@ class SetupAppTestCase(base.BaseTestCase): pecan_config = mock.Mock() pecan_config.app = {'root': 'fake_pecan_config'} make_app.return_value = 'fake_app' + session_middleware.return_value = 'fake_app_with_middleware' result = app.setup_app(pecan_config) - self.assertEqual(result, 'fake_app') + self.assertEqual(result, 'fake_app_with_middleware') app_conf = dict(pecan_config.app) make_app.assert_called_once_with( @@ -214,3 +226,10 @@ class SetupAppTestCase(base.BaseTestCase): template_path='fake_template_path', hooks=['cors_hook', 'json_error_hook', 'request_viewer_hook'] ) + session_middleware.assert_called_once_with( + 'fake_app', + {'session.key': 'refstack', + 'session.type': 'memory', + 'session.timeout': 604800, + 'session.validate_key': get_token.return_value} + ) diff --git a/refstack/tests/unit/test_db.py b/refstack/tests/unit/test_db.py index f09f397e..905150fe 100644 --- a/refstack/tests/unit/test_db.py +++ b/refstack/tests/unit/test_db.py @@ -55,6 +55,18 @@ class DBAPITestCase(base.BaseTestCase): db.get_test_records_count(filters) mock_db.assert_called_once_with(filters) + @mock.patch.object(api, 'user_get') + def test_user_get(self, mock_db): + user_openid = 'user@example.com' + db.user_get(user_openid) + mock_db.assert_called_once_with(user_openid) + + @mock.patch.object(api, 'user_update_or_create') + def test_user_update_or_create(self, mock_db): + user_info = 'user@example.com' + db.user_update_or_create(user_info) + mock_db.assert_called_once_with(user_info) + class DBHelpersTestCase(base.BaseTestCase): """Test case for database backend helpers.""" @@ -261,3 +273,48 @@ class DBBackendTestCase(base.BaseTestCase): session.query.assert_called_once_with(mock_model.id) mock_apply.assert_called_once_with(query, filters) apply_result.count.assert_called_once_with() + + @mock.patch.object(api, 'get_session', + return_value=mock.Mock(name='session'),) + @mock.patch('refstack.db.sqlalchemy.models.User') + def test_user_get(self, mock_model, mock_get_session): + user_openid = 'user@example.com' + session = mock_get_session.return_value + query = session.query.return_value + filtered = query.filter_by.return_value + user = filtered.first.return_value + + result = api.user_get(user_openid) + self.assertEqual(result, user) + + session.query.assert_called_once_with(mock_model) + query.filter_by.assert_called_once_with(openid=user_openid) + filtered.first.assert_called_once_with() + + @mock.patch.object(api, 'get_session', + return_value=mock.Mock(name='session'),) + @mock.patch('refstack.db.sqlalchemy.models.User') + def test_user_get_none(self, mock_model, mock_get_session): + user_openid = 'user@example.com' + session = mock_get_session.return_value + query = session.query.return_value + filtered = query.filter_by.return_value + filtered.first.return_value = None + self.assertRaises(api.UserNotFound, api.user_get, user_openid) + + @mock.patch.object(api, 'get_session') + @mock.patch('refstack.db.sqlalchemy.models.User') + @mock.patch.object(api, 'user_get', side_effect=api.UserNotFound) + def test_user_update_or_create(self, mock_get_user, mock_model, + mock_get_session): + user_info = {'openid': 'user@example.com'} + session = mock_get_session.return_value + user = mock_model.return_value + result = api.user_update_or_create(user_info) + self.assertEqual(result, user) + + mock_model.assert_called_once_with() + mock_get_session.assert_called_once_with() + user.save.assert_called_once_with(session=session) + user.update.assert_called_once_with(user_info) + session.begin.assert_called_once_with() diff --git a/requirements.txt b/requirements.txt index a7609559..28b8780c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ SQLAlchemy>=0.8.3 alembic==0.5.0 +beaker==1.6.5.post1 #gunicorn 19.1.1 has a bug with threading module gunicorn==18 oslo.config>=1.6.0 # Apache-2.0