diff options
author | Lars Wirzenius <liw@liw.fi> | 2018-10-27 12:58:00 +0300 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2018-10-28 10:10:47 +0200 |
commit | 8f5c7b0fc2b273e55b2116ddece21ac2c19fa863 (patch) | |
tree | 7f12c091b7ec63b2953695cee3f9be3685d4373d /yarns | |
parent | cc2d1b21e67643e237d968793d31b7b9437a1640 (diff) | |
download | muck-poc-8f5c7b0fc2b273e55b2116ddece21ac2c19fa863.tar.gz |
Add: HTTP API, with scenario tests
Diffstat (limited to 'yarns')
-rw-r--r-- | yarns/100-happy.yarn | 97 | ||||
-rw-r--r-- | yarns/900-implements.yarn | 69 | ||||
-rw-r--r-- | yarns/lib.py | 168 |
3 files changed, 334 insertions, 0 deletions
diff --git a/yarns/100-happy.yarn b/yarns/100-happy.yarn new file mode 100644 index 0000000..dc6f6c1 --- /dev/null +++ b/yarns/100-happy.yarn @@ -0,0 +1,97 @@ +# A happy path scenario + +This scenario does some basic resource management via the Muck API. + + SCENARIO Muck + +Start Muck. This also sets up access to it for the user by getting an +access token, which will be used for all requests. + + GIVEN a running Muck + +Create a simple resource. Remember its id. + + WHEN user makes request POST /res with body { "foo": "bar" } + THEN status code is 201 + THEN remember resource id as ID + THEN remember resource revision as REV1 + +Retrieve the resource. + + WHEN user makes request GET /res with header "Muck-Id: ${ID}" + THEN status code is 200 + THEN response body is { "foo": "bar" } + THEN response has header "Muck-Id: ${ID}" + THEN response has header "Muck-Revision: ${REV1}" + +Update the resource. + + WHEN user makes request PUT /res with header "Muck-Id: ${ID}" and + ... header "Muck-Revision: wrong" and + ... body { "foo": "foobar" } + THEN status code is 400 + + WHEN user makes request PUT /res with header "Muck-Id: ${ID}" and + ... header "Muck-Revision: ${REV1}" and + ... body { "foo": "foobar" } + THEN status code is 200 + THEN remember resource revision as REV2 + +Check the resource has been updated. + + WHEN user makes request GET /res with header "Muck-Id: ${ID}" + THEN status code is 200 + THEN response body is { "foo": "foobar" } + THEN response has header "Muck-Id: ${ID}" + THEN response has header "Muck-Revision: ${REV2}" + +Restart Muck. The resource should still exist. + + WHEN Muck is restarted + WHEN user makes request GET /res with header "Muck-Id: ${ID}" + THEN status code is 200 + THEN response body is { "foo": "foobar" } + THEN response has header "Muck-Id: ${ID}" + THEN response has header "Muck-Revision: ${REV2}" + +Search for the resource. First with a condition that is no longer +true. + + WHEN user makes request GET /search with body + ... { + ... "cond": [ + ... {"where": "data", "field": "foo", "pattern": "bar", "op": "=="} + ... ] + ... } + THEN status code is 200 + THEN response body is {"resources": []} + +Now search for the correct value. + + WHEN user makes request GET /search with body + ... { + ... "cond": [ + ... {"where": "data", "field": "foo", "pattern": "foobar", + ... "op": "=="} + ... ] + ... } + THEN status code is 200 + THEN response body is {"resources": ["${ID}"]} + +Delete the resource. + + WHEN user makes request DELETE /res with header "Muck-Id: ${ID}" + THEN status code is 200 + + WHEN user makes request GET /res with header "Muck-Id: ${ID}" + THEN status code is 404 + +Restart Muck again. The resource should not exist. + + WHEN Muck is restarted + WHEN user makes request GET /res with header "Muck-Id: ${ID}" + THEN status code is 404 + +All done. + + FINALLY Muck is stopped diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn new file mode 100644 index 0000000..7eb0c86 --- /dev/null +++ b/yarns/900-implements.yarn @@ -0,0 +1,69 @@ +# Scenario step implementations + +## Start and stop Muck + + IMPLEMENTS GIVEN a running Muck + start_muck() + + IMPLEMENTS WHEN Muck is restarted + stop_muck() + start_muck() + + IMPLEMENTS FINALLY Muck is stopped + stop_muck() + +## HTTP requests + + IMPLEMENTS WHEN user makes request POST /res with body (.*) + body = get_expanded_match() + POST('/res', {}, json.loads(body)) + + IMPLEMENTS WHEN user makes request GET /res with header "(\S+): (.+)" + header = get_expanded_match() + value = get_expanded_match() + GET('/res', {header:value}) + + IMPLEMENTS WHEN user makes request GET /search with body (.+) + body = json.loads(get_expanded_match()) + GET('/search', {}, body=body) + + IMPLEMENTS WHEN user makes request PUT /res with header "(\S+): (.+)" and header "(\S+): (.+)" and body (.+) + header1 = get_expanded_match() + value1 = get_expanded_match() + header2 = get_expanded_match() + value2 = get_expanded_match() + body = get_expanded_match() + headers = { + header1: value1, + header2: value2, + } + PUT('/res', headers, json.loads(body)) + + IMPLEMENTS WHEN user makes request DELETE /res with header "(\S+): (.+)" + header = get_expanded_match() + value = get_expanded_match() + DELETE('/res', {header:value}) + +## Checking HTTP responses + + IMPLEMENTS THEN status code is (\d+) + expected = int(get_expanded_match()) + assertEqual(V['status_code'], expected) + + IMPLEMENTS THEN remember resource id as (\S+) + name = get_next_match() + save_header('Muck-Id', name) + + IMPLEMENTS THEN remember resource revision as (\S+) + name = get_next_match() + save_header('Muck-Revision', name) + + IMPLEMENTS THEN response has header "(\S+): (.+)" + name = get_next_match() + expected = get_expanded_match() + assertEqual(get_header(name), expected) + + IMPLEMENTS THEN response body is (.+) + expected = get_expanded_match() + print 'expected:', expected + assertEqual(get_json_body(), json.loads(expected)) diff --git a/yarns/lib.py b/yarns/lib.py new file mode 100644 index 0000000..ee36f9f --- /dev/null +++ b/yarns/lib.py @@ -0,0 +1,168 @@ +# Copyright (C) 2018 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 signal +import subprocess +import time + +import Crypto.PublicKey.RSA +import jwt +import requests + +from yarnutils import * + + +srcdir = os.environ['SRCDIR'] +datadir = os.environ['DATADIR'] +V = Variables(datadir) + + +json_mime_type = 'application/json' + + +def start_muck(): + pathname = os.path.join(srcdir, 'muck_poc') + pubkey = os.path.join(srcdir, 'test-key.pub') + + config = { + 'pid': 'muck.pid', + 'log': 'muck.log', + 'store': datadir, + 'signing-key-filename': pubkey, + } + + config_filename = os.path.join(datadir, 'mock.conf') + with open(config_filename, 'w') as f: + json.dump(config, f) + + out = os.path.join(datadir, 'muck.out') + err = os.path.join(datadir, 'muck.err') + argv = [ + '/usr/sbin/daemonize', '-o', out, '-e', err, '-c', '.', + pathname, config_filename, + ] + subprocess.check_call(argv) + V['base_url'] = 'http://127.0.0.1:{}'.format(12765) + + V['token'] = create_test_token() + + +def stop_muck(): + pid = int(read('muck.pid')) + os.kill(pid, signal.SIGTERM) + + +def create_test_token(): + key_filename = os.path.join(srcdir, 'test-key') + key_text = open(key_filename).read() + + iss = 'test-issuer' + aud = 'test-audience' + sub = 'test-user' + scopes = ['create', 'update', 'show', 'delete'] + lifetime = 3600 + + return create_token(key_text, iss, aud, sub, scopes, lifetime) + +def create_token(key_text, iss, aud, sub, scopes, lifetime): + key = Crypto.PublicKey.RSA.importKey(key_text) + + now = int(time.time()) + claims = { + 'iss': iss, + 'sub': sub, + 'aud': aud, + 'exp': now + lifetime, + 'scope': ' '.join(scopes), + } + + token = jwt.encode(claims, key.exportKey('PEM'), algorithm='RS512') + return token.decode('ascii') + + +def POST(path, headers, body): + return request(requests.post, path, headers, body) + + +def PUT(path, headers, body): + return request(requests.put, path, headers, body) + + +def GET(path, headers, body=None): + return request(requests.get, path, headers, body=body) + + +def DELETE(path, headers): + return request(requests.delete, path, headers) + + +def request(func, path, headers, body=None): + url = '{}{}'.format(V['base_url'], path) + if 'Content-Type' not in headers: + headers['Content-Type'] = json_mime_type + if 'Authorization' not in headers: + headers['Authorization'] = 'Bearer {}'.format(V['token']) + if body is not None: + body = json.dumps(body) + V['request_url'] = url + V['request_func'] = repr(func) + V['request_headers'] = repr(headers) + V['request_body'] = repr(body) + r = func(url, headers=headers, data=body) + V['status_code'] = r.status_code + V['response_body'] = r.text + V['response_headers'] = dict(r.headers) + return r + + +def read(filename): + with open(filename, 'r') as f: + return f.read() + + +def get_expanded_match(): + match = get_next_match() + return expand(match, V) + + +def expand(text, variables): + result = '' + while text: + m = re.search(r'\${(?P<name>[^}]+)}', text) + if not m: + result += text + break + name = m.group('name') + print('expanding ', name, repr(variables[name])) + result += text[:m.start()] + variables[name] + text = text[m.end():] + return result + + +def get_json_body(): + return json.loads(V['response_body']) + + +def get_header(header_name): + headers = V['response_headers'] + assert headers is not None + return headers.get(header_name, '') + + +def save_header(header_name, var_name): + V[var_name] = get_header(header_name) |