summaryrefslogtreecommitdiff
path: root/yarns
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2017-11-16 15:16:18 +0100
committerLars Wirzenius <liw@liw.fi>2017-11-16 15:16:18 +0100
commitb704146516631f5e561b50ae3a1154f306ffb955 (patch)
tree0e4ece80fdf220829f014f3edb4537a78d32b6c4 /yarns
parent58adf7f5e50e437a445213964298f6bfe0c39fb4 (diff)
downloadqvisqve-b704146516631f5e561b50ae3a1154f306ffb955.tar.gz
Add: beginnings of a yarn test suite
Diffstat (limited to 'yarns')
-rw-r--r--yarns/900-implements.yarn200
-rw-r--r--yarns/lib.py157
-rw-r--r--yarns/smoke.yarn26
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