mirror of
https://github.com/ansible/tower-cli.git
synced 2026-02-07 03:47:37 +01:00
Added dependencies and related to POSSIBLE_TYPES They are needed for the import functionality i before e except after c Fixed typo in receive Added receive option Changed classname from Destroyer to Cleaner Added empty command Added required=False to scm-type field. This field is not required by the API to create a project Removed items from export if managed_by_tower = True Added credential_type to dependencies for a credential This allows resoluation of a credential_type when importing a credential Added send command Missing __init__.py file Added type parameter to cleaner Send and Receive now can do json or yaml Removed possible types, adding its items into resource objects Changed how assets are specified for export Added exception if nothing was passed in to process Added all option to cleaner method Fixed issue where properties added in the source were not caught as needed to be updated Added send/recieve/empty docs Updated help text for empty Move removeEncryptedValues to common Added deepclone of asset when determining if asset needs update This fixed an issue when removing items in the asset that were later referenced Fixed some issues when importing credentials Added workflow nodes import/export Initial commit of inventory groups/hosts/sources and fix for vault_creds in job_template Updated API to strip off /api/vX if passed in as url This allows you send URLs retreived from the API back into TowerCLI Caught additional exception If already parsed Java is passed in you could get a TypeError exception which was not caught. Added notification types Added workflow name to error message when extracting nodes When this condition occurs the message is displayed but in a receive no details are present as to what workflow had the error Added all_pages when requesting workflow_nodes Allowed for multiple tokens to be saved in ~/.tower_cli_token.json Added debug log if asset does not have POST options Removed unused import and debug message If an object does not have POST options, just return Changed how errors are logged Added project updates if asset was created or updated Removed local_path from SCM based projects Changed removal of encrypted values from asset to exported_asset Added workflow inventory and credential resolution Added inventory group import/export Added additional credential types for export/import Added options for passwords for new items Variablized workdlow node types Fixed workflow comparison Spell checking All post PR fixes for send/receive Modified print statements for python3 Wrapped TowerCLI class in try This should give us a better error when performing exports Don't get source project if its None Fixed user passwords Moved projects before inventory An inventory with a source from a project requires projects to be created first Added inventory source_script resolution Another change for comparing workflow nodes Revert "Merge remote-tracking branch 'upstream/master'" This reverts commit d8b0d2dbff2713a86aee4c7ca901d789b6e1d470, reversing changes made to 87ea5d2c6118f797dd908404974e790df8e4059a. Fixed unintended injection of documentation Added todo Removed comments for pull request Removed commants on dropping format Fixed bad merge Updated for flake8 Added all_pages to lists Better handeling of extra_vars Multiple changes Made empty take params like receive Output of send and empty are now ansible-ish Changed function name to python standards Extracted 'work' into method for calling programatically Fixed missing : Fixed newline at end of file causing flake8 failure removed closing #'s Changed from passing on TypeError to continuing if not a string Added min_value and max_value to list of known types Changed from .prefix to .get_prefix() Moved out touchup_extra_vars so that it applies to all asset_types Fixed issue where tox was sending back a json_token of 'invalid' causing issues with the dict Added missing POST endpoint Use six for string compare
290 lines
13 KiB
Python
290 lines
13 KiB
Python
# Copyright 2015, Ansible, Inc.
|
|
# Luke Sneeringer <lsneeringer@ansible.com>
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import json
|
|
import warnings
|
|
from datetime import datetime as dt, timedelta
|
|
|
|
import requests
|
|
from requests.sessions import Session
|
|
|
|
from tower_cli.api import APIResponse, client, BasicTowerAuth,\
|
|
TOWER_DATETIME_FMT
|
|
from tower_cli import exceptions as exc
|
|
from tower_cli.conf import settings
|
|
from tower_cli.utils import debug
|
|
from tower_cli.utils.data_structures import OrderedDict
|
|
from tower_cli.constants import CUR_API_VERSION
|
|
|
|
from tests.compat import unittest, mock
|
|
import click
|
|
|
|
REQUESTS_ERRORS = [requests.exceptions.ConnectionError,
|
|
requests.exceptions.SSLError]
|
|
|
|
|
|
class ClientTests(unittest.TestCase):
|
|
"""A set of tests to ensure that the API Client class works in the
|
|
way that we expect.
|
|
"""
|
|
def test_prefix_implicit_https(self):
|
|
"""Establish that the prefix property returns the appropriate
|
|
URL prefix given a host with no specified protocol.
|
|
"""
|
|
with settings.runtime_values(host='33.33.33.33'):
|
|
self.assertEqual(client.get_prefix(), 'https://33.33.33.33/api/%s/' % CUR_API_VERSION)
|
|
|
|
def test_prefix_explicit_protocol(self):
|
|
"""Establish that the prefix property returns the appropriate
|
|
URL prefix and don't clobber over an explicit protocol.
|
|
"""
|
|
with settings.runtime_values(host='bogus://33.33.33.33/'):
|
|
self.assertEqual(client.get_prefix(), 'bogus://33.33.33.33/api/%s/' % CUR_API_VERSION)
|
|
|
|
def test_request_ok(self):
|
|
"""Establish that a request that returns a valid JSON response
|
|
returns without incident and comes back as an APIResponse.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register_json('/ping/', {'status': 'ok'})
|
|
r = client.get('/ping/')
|
|
|
|
# Establish that our response is an APIResponse and that our
|
|
# JSONification method returns back an ordered dict.
|
|
self.assertIsInstance(r, APIResponse)
|
|
self.assertIsInstance(r.json(), OrderedDict)
|
|
|
|
# Establish that our headers have expected auth.
|
|
request = r.request
|
|
self.assertEqual(request.headers['Authorization'],
|
|
'Basic bWVhZ2FuOlRoaXMgaXMgdGhlIGJlc3Qgd2luZS4=')
|
|
|
|
# Make sure the content matches what we expect.
|
|
self.assertEqual(r.json(), {'status': 'ok'})
|
|
|
|
def test_request_post(self):
|
|
"""Establish that on a POST request, we encode the provided data
|
|
to JSON automatically.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register_json('/ping/', {'status': 'ok'}, method='POST')
|
|
r = client.post('/ping/', {'payload': 'this is my payload.'})
|
|
|
|
# Establish that our request has the expected payload, and
|
|
# is sent using an application/json content type.
|
|
headers = r.request.headers
|
|
self.assertEqual(headers['Content-Type'], 'application/json')
|
|
self.assertEqual(r.request.body,
|
|
'{"payload": "this is my payload."}')
|
|
|
|
def test_connection_ssl_error(self):
|
|
"""Establish that if we get a ConnectionError or an SSLError
|
|
back from requests, that we deal with it nicely.
|
|
"""
|
|
for ErrorType in REQUESTS_ERRORS:
|
|
with settings.runtime_values(verbose=False, host='https://foo.co'):
|
|
with mock.patch.object(Session, 'request') as req:
|
|
req.side_effect = ErrorType
|
|
with self.assertRaises(exc.ConnectionError):
|
|
client.get('/ping/')
|
|
|
|
def test_connection_ssl_error_verbose(self):
|
|
"""Establish that if we get a ConnectionError or an SSLError
|
|
back from requests, that we deal with it nicely, and
|
|
additionally print the internal error if verbose is True.
|
|
"""
|
|
for ErrorType in REQUESTS_ERRORS:
|
|
with settings.runtime_values(verbose=True, host='https://foo.co'):
|
|
with mock.patch.object(Session, 'request') as req:
|
|
req.side_effect = ErrorType
|
|
with mock.patch.object(debug, 'log') as dlog:
|
|
with self.assertRaises(exc.ConnectionError):
|
|
client.get('/ping/')
|
|
self.assertEqual(dlog.call_count, 5)
|
|
|
|
def test_server_error(self):
|
|
"""Establish that server errors raise the ServerError
|
|
exception as expected.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register('/ping/', 'ERRORED!!', status_code=500)
|
|
with self.assertRaises(exc.ServerError):
|
|
client.get('/ping/')
|
|
|
|
def test_auth_error(self):
|
|
"""Establish that authentication errors raise the AuthError
|
|
exception.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register('/ping/', 'ERRORED!!', status_code=401)
|
|
with self.assertRaises(exc.AuthError):
|
|
client.get('/ping/')
|
|
|
|
def test_forbidden_error(self):
|
|
"""Establish that forbidden errors raise the ForbiddenError
|
|
exception.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register('/ping/', 'ERRORED!!', status_code=403)
|
|
with self.assertRaises(exc.Forbidden):
|
|
client.get('/ping/')
|
|
|
|
def test_not_found_error(self):
|
|
"""Establish that authentication errors raise the NotFound
|
|
exception.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register('/ping/', 'ERRORED!!', status_code=404)
|
|
with self.assertRaises(exc.NotFound):
|
|
client.get('/ping/')
|
|
|
|
def test_method_not_allowed_error(self):
|
|
"""Establish that authentication errors raise the MethodNotAllowed
|
|
exception.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register('/ping/', 'ERRORED!!', status_code=405)
|
|
with self.assertRaises(exc.MethodNotAllowed):
|
|
client.get('/ping/')
|
|
|
|
def test_bad_request_error(self):
|
|
"""Establish that other errors not covered above raise the
|
|
BadRequest exception.
|
|
"""
|
|
with client.test_mode as t:
|
|
t.register('/ping/', "I'm a teapot!", status_code=418)
|
|
with self.assertRaises(exc.BadRequest):
|
|
client.get('/ping/')
|
|
|
|
def test_insecure_connection(self):
|
|
"""Establish that the --insecure flag will cause the program to
|
|
call request with verify=False.
|
|
"""
|
|
with mock.patch('requests.sessions.Session.request') as g:
|
|
mock_response = type('statobj', (), {})() # placeholder object
|
|
mock_response.status_code = 200
|
|
g.return_value = mock_response
|
|
with client.test_mode as t:
|
|
t.register('/ping/', "I'm a teapot!", status_code=200)
|
|
with settings.runtime_values(verify_ssl=False):
|
|
client.get('/ping/')
|
|
g.assert_called_once_with(
|
|
# The point is to assure verify=False below
|
|
'GET', mock.ANY, allow_redirects=True,
|
|
auth=mock.ANY, verify=False
|
|
)
|
|
|
|
def test_http_contradiction_error(self):
|
|
"""Establish that commands can not be ran with verify_ssl set
|
|
to false and an http connection."""
|
|
with settings.runtime_values(
|
|
host='http://33.33.33.33', verify_ssl=True):
|
|
with self.assertRaises(exc.TowerCLIError):
|
|
client.get_prefix()
|
|
|
|
def test_failed_suggestion_protocol(self):
|
|
"""Establish that if connection fails and protocol not given,
|
|
tower-cli suggests that to the user."""
|
|
with settings.runtime_values(verbose=False, host='foo.co'):
|
|
with mock.patch.object(Session, 'request') as req:
|
|
req.side_effect = requests.exceptions.SSLError
|
|
with mock.patch.object(click, 'secho') as secho:
|
|
with self.assertRaises(exc.ConnectionError):
|
|
client.get('/ping/')
|
|
self.assertTrue(secho.called)
|
|
|
|
|
|
class TowerAuthTokenTests(unittest.TestCase):
|
|
def setUp(self):
|
|
|
|
class Req(object):
|
|
def __init__(self):
|
|
self.headers = {}
|
|
|
|
with settings.runtime_values(use_token=True):
|
|
self.auth = BasicTowerAuth('alice', 'pass', client)
|
|
self.req = Req()
|
|
self.expires = dt.utcnow()
|
|
|
|
def test_reading_valid_token(self):
|
|
self.expires += timedelta(hours=1)
|
|
expires = self.expires.strftime(TOWER_DATETIME_FMT)
|
|
with mock.patch('six.moves.builtins.open', new_callable=mock.mock_open()):
|
|
with mock.patch('tower_cli.api.json.load', return_value={'token': 'foobar', 'expires': expires}):
|
|
with client.test_mode as t:
|
|
t.register('/authtoken/', json.dumps({}), status_code=200, method='OPTIONS')
|
|
t.register('/authtoken/', json.dumps({'token': 'foobar', 'expires': expires}), status_code=200,
|
|
method='POST')
|
|
self.auth(self.req)
|
|
self.assertEqual(self.req.headers['Authorization'], 'Token foobar')
|
|
|
|
def test_reading_invalid_token(self):
|
|
self.expires += timedelta(hours=1)
|
|
expires = self.expires.strftime(TOWER_DATETIME_FMT)
|
|
with mock.patch('six.moves.builtins.open', new_callable=mock.mock_open()):
|
|
with mock.patch('tower_cli.api.json.load', return_value="invalid"):
|
|
with client.test_mode as t:
|
|
t.register('/authtoken/', json.dumps({}), status_code=200, method='OPTIONS')
|
|
t.register('/authtoken/', json.dumps({'token': 'barfoo', 'expires': expires}), status_code=200,
|
|
method='POST')
|
|
self.auth(self.req)
|
|
self.assertEqual(self.req.headers['Authorization'], 'Token barfoo')
|
|
|
|
def test_reading_expired_token(self):
|
|
self.expires += timedelta(hours=-1)
|
|
expires = self.expires.strftime(TOWER_DATETIME_FMT)
|
|
with mock.patch('six.moves.builtins.open', new_callable=mock.mock_open()):
|
|
with mock.patch('tower_cli.api.json.load', return_value={'token': 'foobar', 'expires': expires}):
|
|
with client.test_mode as t:
|
|
t.register('/authtoken/', json.dumps({}), status_code=200, method='OPTIONS')
|
|
t.register('/authtoken/', json.dumps({'token': 'barfoo', 'expires': expires}), status_code=200,
|
|
method='POST')
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
self.auth(self.req)
|
|
self.assertEqual(self.req.headers['Authorization'], 'Token barfoo')
|
|
|
|
def test_reading_invalid_token_from_server(self):
|
|
self.expires += timedelta(hours=-1)
|
|
expires = self.expires.strftime(TOWER_DATETIME_FMT)
|
|
with mock.patch('six.moves.builtins.open', new_callable=mock.mock_open()):
|
|
with mock.patch('tower_cli.api.json.load', return_value={'token': 'foobar', 'expires': expires}):
|
|
with client.test_mode as t:
|
|
with self.assertRaises(exc.AuthError):
|
|
t.register('/authtoken/', json.dumps({}), status_code=200, method='OPTIONS')
|
|
t.register('/authtoken/', json.dumps({'invalid': 'invalid'}), status_code=200, method='POST')
|
|
self.auth(self.req)
|
|
|
|
def test_auth_token_unsupported(self):
|
|
# If the user specifies `use_token=True`, but `/authtoken/` doesn't
|
|
# exist (in Tower 3.3 and beyond), just fall back to basic auth
|
|
with client.test_mode as t:
|
|
with settings.runtime_values(use_token=True):
|
|
t.register('/authtoken/', json.dumps({}), status_code=404, method='OPTIONS')
|
|
auth = BasicTowerAuth('alice', 'pass', client)
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
auth(self.req)
|
|
assert self.req.headers == {'Authorization': 'Basic YWxpY2U6cGFzcw=='}
|
|
|
|
def test_oauth_bearer_token(self):
|
|
token = 'Azly3WBiYWeGKfImK25ftpJR1nvn6JABC123'
|
|
with settings.runtime_values(oauth_token=token):
|
|
auth = BasicTowerAuth(None, None, client)
|
|
auth(self.req)
|
|
assert self.req.headers == {
|
|
'Authorization': 'Bearer {}'.format(token)
|
|
}
|