diff options
author | Lars Wirzenius <liw@liw.fi> | 2017-11-16 15:16:18 +0100 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2017-11-16 15:16:18 +0100 |
commit | b704146516631f5e561b50ae3a1154f306ffb955 (patch) | |
tree | 0e4ece80fdf220829f014f3edb4537a78d32b6c4 /yarns | |
parent | 58adf7f5e50e437a445213964298f6bfe0c39fb4 (diff) | |
download | qvisqve-b704146516631f5e561b50ae3a1154f306ffb955.tar.gz |
Add: beginnings of a yarn test suite
Diffstat (limited to 'yarns')
-rw-r--r-- | yarns/900-implements.yarn | 200 | ||||
-rw-r--r-- | yarns/lib.py | 157 | ||||
-rw-r--r-- | yarns/smoke.yarn | 26 |
3 files changed, 383 insertions, 0 deletions
diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn new file mode 100644 index 0000000..a8678e9 --- /dev/null +++ b/yarns/900-implements.yarn @@ -0,0 +1,200 @@ +# Step implementations + +This chapter shows the scenario step implementations. + +## Start and stop Salami + +Start a Salami running in the background. + + IMPLEMENTS GIVEN a running salami instance + import os, time, cliapp, yaml, yarnutils + privkey, pubkey = create_token_signing_key_pair() + open('key', 'w').write(privkey) + vars['aud'] = 'http://api.test.example.com' + vars['iss'] = 'salami.yarn' + vars['privkey'] = privkey + vars['pubkey'] = pubkey + vars['api.log'] = 'salami.log' + vars['gunicorn3.log'] = 'gunicorn3.log' + vars['pid-file'] = 'pid' + vars['port'] = cliapp.runcmd([os.path.join(srcdir, 'randport' )]).strip() + vars['API_URL'] = 'http://127.0.0.1:{}'.format(vars['port']) + config = { + 'log': [ + { + 'filename': vars['api.log'], + }, + ], + 'token-public-key': vars['pubkey'], + 'token-issuer': vars['iss'], + 'token-audience': vars['aud'], + } + config = add_postgres_config(config) + env = dict(os.environ) + env['SALAMI_CONFIG'] = os.path.join(datadir, 'salami.yaml') + yaml.safe_dump(config, open(env['SALAMI_CONFIG'], 'w')) + argv = [ + 'gunicorn3', + '--daemon', + '--bind', '127.0.0.1:{}'.format(vars['port']), + '-p', vars['pid-file'], + 'salami.backend:app', + ] + cliapp.runcmd(argv, env=env, stdout=None, stderr=None) + until = time.time() + 2.0 + while time.time() < until and not os.path.exists(vars['pid-file']): + time.sleep(0.01) + assert os.path.exists(vars['pid-file']) + +## Stop a Salami we started + + IMPLEMENTS FINALLY salami is stopped + import os, signal, yarnutils + filename = vars['pid-file'] + if os.path.exists(filename): + pid = int(cat(filename)) + os.kill(pid, signal.SIGTERM) + +## API requests of various kinds + + IMPLEMENTS WHEN client requests GET (/.+) without token + path = get_next_match() + path = expand_vars(path, vars) + vars['status_code'], vars['headers'], vars['body'] = get(vars['API_URL'] + path) + + IMPLEMENTS WHEN client requests GET (/.+) using token + path = get_next_match() + path = expand_vars(path, vars) + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + } + vars['status_code'], vars['headers'], vars['body'] = get( + vars['API_URL'] + path, headers) + + IMPLEMENTS WHEN client requests POST (/.+) with token and body (.+) + path = get_next_match() + body = get_next_match() + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + 'Content-Type': 'application/json', + } + vars['status_code'], vars['headers'], vars['body'] = post( + vars['API_URL'] + path, headers=headers, body=body) + + IMPLEMENTS WHEN client requests PUT (/.+) with token and body (.+) + path = get_next_match() + path = expand_vars(path, vars) + body = get_next_match() + body = expand_vars(body, vars) + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + 'Content-Type': 'application/json', + } + vars['status_code'], vars['headers'], vars['body'] = put( + vars['API_URL'] + path, headers=headers, body=body) + + IMPLEMENTS WHEN client requests PUT (/[a-z0-9/${}]+) with token, revision (\S+), content-type (\S+), and empty body + path = expand_vars(get_next_match(), vars) + revision = expand_vars(get_next_match(), vars) + ctype = expand_vars(get_next_match(), vars) + body = '' + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + 'Revision': revision, + 'Content-Type': ctype, + } + vars['status_code'], vars['headers'], vars['body'] = put( + vars['API_URL'] + path, headers=headers, body=body) + + IMPLEMENTS WHEN client requests PUT (/[a-z0-9/${}]+) with token, revision (\S+), content-type (\S+), and body "(.+)" + path = expand_vars(get_next_match(), vars) + revision = expand_vars(get_next_match(), vars) + ctype = expand_vars(get_next_match(), vars) + body = unescape(expand_vars(get_next_match(), vars)) + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + 'Revision': revision, + 'Content-Type': ctype, + } + vars['status_code'], vars['headers'], vars['body'] = put( + vars['API_URL'] + path, headers=headers, body=body) + + IMPLEMENTS WHEN client requests DELETE (/.+) with token + path = get_next_match() + path = expand_vars(path, vars) + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + } + vars['status_code'], vars['headers'], vars['body'] = delete( + vars['API_URL'] + path, headers=headers) + +## API access token creation + + IMPLEMENTS WHEN client gets an authorization token with scope "(.+)" + scopes = get_next_match() + print 'privkey', repr(vars['privkey']) + assert vars['privkey'] + vars['token'] = create_token(vars['privkey'], vars['iss'], vars['aud'], scopes) + +## UUID creation + + IMPLEMENTS GIVEN unique random identifier (\S+) + import uuid + name = get_next_match() + vars[name] = str(uuid.uuid4()) + +## API request result checking + + IMPLEMENTS THEN HTTP status code is (\d+) (.*) + expected = int(get_next_match()) + assertEqual(vars['status_code'], expected) + + IMPLEMENTS THEN HTTP (\S+) header is (.+) + header = get_next_match() + value = expand_vars(get_next_match(), vars) + assertEqual(vars['headers'].get(header), value) + + IMPLEMENTS THEN remember HTTP (\S+) header as (.+) + header = get_next_match() + name = get_next_match() + vars[name] = vars['headers'].get(header) + + IMPLEMENTS THEN resource id is (\S+) + import json + name = get_next_match() + print 'body:', repr(vars['body']) + body = json.loads(vars['body']) + vars[name] = body['id'] + + IMPLEMENTS THEN revision is (\S+) + import json + name = get_next_match() + body = json.loads(vars['body']) + vars[name] = body['revision'] + + IMPLEMENTS THEN revisions (\S+) and (\S+) are different + rev1 = get_next_match() + rev2 = get_next_match() + assertNotEqual(vars[rev1], vars[rev2]) + + IMPLEMENTS THEN revisions (\S+) and (\S+) match + rev1 = get_next_match() + rev2 = get_next_match() + assertEqual(vars[rev1], vars[rev2]) + + IMPLEMENTS THEN JSON body matches (.+) + import json + wanted = get_next_match() + print 'wanted1', repr(wanted) + wanted = expand_vars(wanted, vars) + print 'wanted2', repr(wanted) + wanted = json.loads(wanted) + actual = json.loads(vars['body']) + print 'actual ', repr(actual) + print 'wanted3', repr(wanted) + assertTrue(values_match(wanted, actual)) + + IMPLEMENTS THEN body is "(.+)" + wanted = unescape(expand_vars(get_next_match(), vars)) + body = vars['body'] + assertTrue(values_match(wanted, body)) diff --git a/yarns/lib.py b/yarns/lib.py new file mode 100644 index 0000000..459a821 --- /dev/null +++ b/yarns/lib.py @@ -0,0 +1,157 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import json +import os +import re +import tempfile + + +import cliapp +import Crypto.PublicKey.RSA +import requests +import yaml + + +from yarnutils import * + + +srcdir = os.environ['SRCDIR'] +datadir = os.environ['DATADIR'] + + +vars = Variables(datadir) + + +def hexdigit(c): + return ord(c) - ord('0') + + +def unescape(s): + t = '' + while s: + if s.startswith('\\x') and len(s) >= 4: + a = hexdigit(s[2]) + b = hexdigit(s[3]) + t += chr(a * 16 + b) + s = s[4:] + else: + t += s[0] + s = s[1:] + return t + + +def add_postgres_config(config): + pg = os.environ.get('QVARN_POSTGRES') + if pg: + with open(pg) as f: + config['database'] = yaml.safe_load(f) + config['memory-database'] = False + return config + + +def get(url, headers=None): + print('get: url={} headers={}'.format(url, headers)) + r = requests.get(url, headers=headers) + return r.status_code, dict(r.headers), r.content + + +def post(url, headers=None, body=None): + r = requests.post(url, headers=headers, data=body) + return r.status_code, dict(r.headers), r.text + + +def put(url, headers=None, body=None): + r = requests.put(url, headers=headers, data=body) + return r.status_code, dict(r.headers), r.text + + +def delete(url, headers=None): + r = requests.delete(url, headers=headers) + return r.status_code, dict(r.headers), r.text + + +def create_token_signing_key_pair(): + RSA_KEY_BITS = 4096 # A nice, currently safe length + key = Crypto.PublicKey.RSA.generate(RSA_KEY_BITS) + return key.exportKey('PEM'), key.exportKey('OpenSSH') + + +def create_token(privkey, iss, aud, scopes): + filename = write_temp(privkey) + argv = [ + os.path.join(srcdir, 'create-token'), + filename, + iss, + aud, + scopes, + ] + return cliapp.runcmd(argv) + + +def cat(filename): + return open(filename).read() + + +def write_temp(data): + fd, filename = tempfile.mkstemp(dir=datadir) + os.write(fd, data) + os.close(fd) + return filename + + +def expand_vars(text, vars): + result = '' + while text: + m = re.search(r'\${(?P<name>[^}]+)}', text) + if not m: + result += text + break + name = m.group('name') + print('expanding ', name) + result += text[:m.start()] + vars[name] + text = text[m.end():] + return result + + +def values_match(wanted, actual): + print + print 'wanted:', repr(wanted) + print 'actual:', repr(actual) + + if type(wanted) != type(actual): + print 'wanted and actual types differ', type(wanted), type(actual) + return False + + if isinstance(wanted, dict): + for key in wanted: + if key not in actual: + print 'key {!r} not in actual'.format(key) + return False + if not values_match(wanted[key], actual[key]): + return False + elif isinstance(wanted, list): + if len(wanted) != len(actual): + print 'wanted and actual are of different lengths' + for witem, aitem in zip(wanted, actual): + if not values_match(witem, aitem): + return False + else: + if wanted != actual: + print 'wanted and actual differ' + return False + + return True diff --git a/yarns/smoke.yarn b/yarns/smoke.yarn new file mode 100644 index 0000000..323b8a3 --- /dev/null +++ b/yarns/smoke.yarn @@ -0,0 +1,26 @@ +--- +title: Salami integration tests +author: Lars Wirzenius / QvarnLabs Ab +date: work in progress +... + + +# Introduction + +This is an integration test suite for Salami, an authorization server. + + +# Version checking + +This scenario tests whether Salami reports it version. This is not +useful as such, but it makes sure we can start and stop Salami, and +that all the request routing works, and so on. + + SCENARIO Salami reports its version + + GIVEN a running salami instance + + WHEN client requests GET /version without token + THEN HTTP status code is 200 OK + + FINALLY salami is stopped |