summaryrefslogtreecommitdiff
path: root/yarns
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-10-27 12:58:00 +0300
committerLars Wirzenius <liw@liw.fi>2018-10-28 10:10:47 +0200
commit8f5c7b0fc2b273e55b2116ddece21ac2c19fa863 (patch)
tree7f12c091b7ec63b2953695cee3f9be3685d4373d /yarns
parentcc2d1b21e67643e237d968793d31b7b9437a1640 (diff)
downloadmuck-poc-8f5c7b0fc2b273e55b2116ddece21ac2c19fa863.tar.gz
Add: HTTP API, with scenario tests
Diffstat (limited to 'yarns')
-rw-r--r--yarns/100-happy.yarn97
-rw-r--r--yarns/900-implements.yarn69
-rw-r--r--yarns/lib.py168
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)