From 8f5c7b0fc2b273e55b2116ddece21ac2c19fa863 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Sat, 27 Oct 2018 12:58:00 +0300 Subject: Add: HTTP API, with scenario tests --- check | 4 + muck/__init__.py | 1 + muck/idgen.py | 22 +++++ muck/idgen_tests.py | 27 ++++++ muck/mem.py | 57 ++++++++++++ muck/mem_tests.py | 226 +++++++++++++++++++++++++++++----------------- muck_poc | 186 ++++++++++++++++++++++++++++++++++++++ test-key | 51 +++++++++++ test-key.pub | 1 + yarns/100-happy.yarn | 97 ++++++++++++++++++++ yarns/900-implements.yarn | 69 ++++++++++++++ yarns/lib.py | 168 ++++++++++++++++++++++++++++++++++ 12 files changed, 824 insertions(+), 85 deletions(-) create mode 100644 muck/idgen.py create mode 100644 muck/idgen_tests.py create mode 100755 muck_poc create mode 100644 test-key create mode 100644 test-key.pub create mode 100644 yarns/100-happy.yarn create mode 100644 yarns/900-implements.yarn create mode 100644 yarns/lib.py diff --git a/check b/check index 6f31bc0..18bae3e 100755 --- a/check +++ b/check @@ -45,5 +45,9 @@ then pylint3 --rcfile pylint.conf $modules fi +title Yarn scenario tests +yarn --shell python2 --shell-arg '' --shell-library=yarns/lib.py --cd-datadir \ + yarns/*.yarn "$@" + title OK echo "All tests pass" diff --git a/muck/__init__.py b/muck/__init__.py index b286cf0..d78e998 100644 --- a/muck/__init__.py +++ b/muck/__init__.py @@ -14,6 +14,7 @@ from .exc import Error +from .idgen import IdGenerator from .change import ( Change, CreateChange, diff --git a/muck/idgen.py b/muck/idgen.py new file mode 100644 index 0000000..a884b8d --- /dev/null +++ b/muck/idgen.py @@ -0,0 +1,22 @@ +# 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 . + + +import uuid + + +class IdGenerator: + + def new_id(self): + return str(uuid.uuid4()) diff --git a/muck/idgen_tests.py b/muck/idgen_tests.py new file mode 100644 index 0000000..ecab4dd --- /dev/null +++ b/muck/idgen_tests.py @@ -0,0 +1,27 @@ +# 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 . + + +import unittest + +import muck + + +class IdGeneratorTests(unittest.TestCase): + + def test_returns_different_value_each_time(self): + gen = muck.IdGenerator() + id1 = gen.new_id() + id2 = gen.new_id() + self.assertNotEqual(id1, id2) diff --git a/muck/mem.py b/muck/mem.py index 437085d..3100a62 100644 --- a/muck/mem.py +++ b/muck/mem.py @@ -13,6 +13,8 @@ # along with this program. If not, see . +import copy + import muck @@ -27,6 +29,61 @@ class MemoryStore: def as_dict(self): return self._dict + def __contains__(self, key): + return key in self._dict + + def __getitem__(self, key): + meta, res = self._dict[key] + meta = copy.deepcopy(meta) + res = copy.deepcopy(res) + return meta, res + + def search(self, conds): + hits = [] + for rid in self._dict: + meta, res = self._dict[rid] + if any(self._matches(meta, res, cond) for cond in conds): + hits.append(rid) + return hits + + def _matches(self, meta, res, cond): + if cond['where'] not in ('meta', 'data'): # pragma: no cover + return False + + if cond['where'] == 'meta': + thing = meta + else: + thing = res + + field = cond['field'] + pattern = cond['pattern'] + + if field not in thing: # pragma: no cover + return False + value = thing[field] + + funcs = { + '==': self._equal, + '>=': self._ge, + '<=': self._le, + } + if cond['op'] not in funcs: # pragma: no cover + return False + is_match = funcs[cond['op']] + + if isinstance(value, list): + return any(is_match(pattern, item) for item in value) + return is_match(pattern, value) + + def _equal(self, expected, actual): + return expected.lower() == actual.lower() + + def _ge(self, expected, actual): + return expected.lower() == actual.lower() + + def _le(self, expected, actual): + return expected.lower() == actual.lower() + def change(self, chg): funcs = { 'create': self._create, diff --git a/muck/mem_tests.py b/muck/mem_tests.py index e858a33..6b7a069 100644 --- a/muck/mem_tests.py +++ b/muck/mem_tests.py @@ -20,119 +20,175 @@ import muck class MemoryStoreTests(unittest.TestCase): - def test_is_initially_empty(self): - ms = muck.MemoryStore() - self.assertEqual(len(ms), 0) - self.assertEqual(ms.as_dict(), {}) - - def test_creates_resource(self): - meta = { + def setUp(self): + self.ms = muck.MemoryStore() + self.meta1 = { 'id': 'id-1', 'rev': 'rev-1', } - - res = { + self.res1 = { 'foo': 'bar', } - chg = muck.CreateChange(meta=meta, res=res) - ms = muck.MemoryStore() - ms.change(chg) - self.assertEqual(len(ms), 1) + def test_is_initially_empty(self): + self.assertEqual(len(self.ms), 0) + self.assertEqual(self.ms.as_dict(), {}) + self.assertFalse('foo' in self.ms) + + def test_creates_resource(self): + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) + + rid = self.meta1['id'] + self.assertEqual(len(self.ms), 1) + self.assertTrue(rid in self.ms) + self.assertEqual(self.ms[rid], (self.meta1, self.res1)) self.assertEqual( - ms.as_dict(), + self.ms.as_dict(), { - 'id-1': (meta, res), + rid: (self.meta1, self.res1), }) def test_wont_create_resource_with_conflicting_id(self): - meta = { - 'id': 'id-1', - 'rev': 'rev-1', - } - - res = { - 'foo': 'bar', - } - - chg = muck.CreateChange(meta=meta, res=res) - ms = muck.MemoryStore() - ms.change(chg) + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) with self.assertRaises(muck.Error): - ms.change(chg) + self.ms.change(chg) def test_updates_resource(self): - meta_1 = { - 'id': 'id-1', - 'rev': 'rev-1', - } + meta2 = dict(self.meta1) + meta2['rev'] = 'rev-2' - res_v1 = { - 'foo': 'bar', - } - - meta_2 = dict(meta_1) - meta_2['rev'] = 'rev-2' + res2 = dict(self.res1) + res2['foo'] = 'yo' - res_v2 = dict(res_v1) - res_v2['foo'] = 'yo' + create = muck.CreateChange(meta=self.meta1, res=self.res1) + update = muck.UpdateChange(meta=meta2, res=res2) - create = muck.CreateChange(meta=meta_1, res=res_v1) - update = muck.UpdateChange(meta=meta_2, res=res_v2) + rid = self.meta1['id'] - ms = muck.MemoryStore() - ms.change(create) - ms.change(update) - self.assertEqual(len(ms), 1) + self.ms.change(create) + self.ms.change(update) + self.assertEqual(len(self.ms), 1) self.assertEqual( - ms.as_dict(), + self.ms.as_dict(), { - 'id-1': (meta_2, res_v2), + rid: (meta2, res2), }) def test_refuses_to_update_resource_that_didnt_exist(self): - meta = { - 'id': 'id-1', - 'rev': 'rev-1', - } - - res = { - 'foo': 'bar', - } - - update = muck.UpdateChange(meta=meta, res=res) - - ms = muck.MemoryStore() + update = muck.UpdateChange(meta=self.meta1, res=self.res1) with self.assertRaises(muck.Error): - ms.change(update) + self.ms.change(update) def test_deletes_resource(self): - meta = { - 'id': 'id-1', - 'rev': 'rev-1', - } - - res = { - 'foo': 'bar', - } - - create = muck.CreateChange(meta=meta, res=res) - delete = muck.DeleteChange(meta=meta) + create = muck.CreateChange(meta=self.meta1, res=self.res1) + delete = muck.DeleteChange(meta=self.meta1) - ms = muck.MemoryStore() - ms.change(create) - ms.change(delete) - self.assertEqual(len(ms), 0) - self.assertEqual(ms.as_dict(), {}) + self.ms.change(create) + self.ms.change(delete) + self.assertEqual(len(self.ms), 0) + self.assertEqual(self.ms.as_dict(), {}) def test_refuses_to_delete_resource_that_doesnt_exist(self): - meta = { - 'id': 'id-1', - 'rev': 'rev-1', - } + delete = muck.DeleteChange(meta=self.meta1) + with self.assertRaises(muck.Error): + self.ms.change(delete) - delete = muck.DeleteChange(meta=meta) + def test_finds_nothing_for_impossible_condition(self): + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) - ms = muck.MemoryStore() - with self.assertRaises(muck.Error): - ms.change(delete) + cond = [ + { + 'where': 'meta', + 'field': 'id', + 'pattern': 'does-not-exist', + 'op': '==', + }, + ] + hits = self.ms.search(cond) + self.assertEqual(hits, []) + + def test_finds_matching_resources_in_meta(self): + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) + + rid = self.meta1['id'] + cond = [ + { + 'where': 'meta', + 'field': 'id', + 'pattern': rid, + 'op': '==', + }, + ] + hits = self.ms.search(cond) + self.assertEqual(hits, [rid]) + + def test_finds_matching_resources_in_data(self): + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) + + rid = self.meta1['id'] + cond = [ + { + 'where': 'data', + 'field': 'foo', + 'pattern': self.res1['foo'], + 'op': '==', + }, + ] + hits = self.ms.search(cond) + self.assertEqual(hits, [rid]) + + def test_finds_matches_in_list(self): + self.res1['foo'] = ['bar', 'yo'] + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) + + rid = self.meta1['id'] + cond = [ + { + 'where': 'data', + 'field': 'foo', + 'pattern': 'yo', + 'op': '==', + }, + ] + hits = self.ms.search(cond) + self.assertEqual(hits, [rid]) + + def test_finds_ge(self): + self.res1['foo'] = ['bar', 'yo'] + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) + + rid = self.meta1['id'] + cond = [ + { + 'where': 'data', + 'field': 'foo', + 'pattern': 'yo', + 'op': '>=', + }, + ] + hits = self.ms.search(cond) + self.assertEqual(hits, [rid]) + + def test_finds_le(self): + self.res1['foo'] = ['bar', 'yo'] + chg = muck.CreateChange(meta=self.meta1, res=self.res1) + self.ms.change(chg) + + rid = self.meta1['id'] + cond = [ + { + 'where': 'data', + 'field': 'foo', + 'pattern': 'bar', + 'op': '<=', + }, + ] + hits = self.ms.search(cond) + self.assertEqual(hits, [rid]) diff --git a/muck_poc b/muck_poc new file mode 100755 index 0000000..40f59ba --- /dev/null +++ b/muck_poc @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# 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 . + + +import json +import logging +import os +import sys + +import bottle + +import muck + + +class MuckAPI: + + def __init__(self, bottleapp, store, signing_key_text): + self._add_routes(bottleapp) + self._store = store + self._gen = muck.IdGenerator() + self._ac = muck.AuthorizationChecker(signing_key_text) + + def _add_routes(self, bottleapp): + routes = [ + { + 'method': 'POST', + 'path': '/res', + 'callback': self._check_authz( + 'POST', 'create', self._create_res), + }, + { + 'method': 'PUT', + 'path': '/res', + 'callback': self._check_authz( + 'PUT', 'update', self._update_res), + }, + { + 'method': 'GET', + 'path': '/res', + 'callback': self._check_authz( + 'GET', 'show', self._show_res), + }, + { + 'method': 'DELETE', + 'path': '/res', + 'callback': self._check_authz( + 'DELETE', 'delete', self._delete_res), + }, + { + 'method': 'GET', + 'path': '/search', + 'callback': self._check_authz( + 'GET', 'show', self._search_res), + }, + ] + + for route in routes: + bottleapp.route(**route) + + def _check_authz(self, req_method, req_scope, callback): + def check_authz(): + r = muck.Request(method=bottle.request.method) + r.add_headers(bottle.request.headers) + if self._ac.request_is_allowed(r, req_method, [req_scope]): + return callback() + logging.error('Access denied') + return bottle.HTTPError(401) + + return check_authz + + def _create_res(self): + res = self._get_json_body() + meta = { + 'id': self._gen.new_id(), + 'rev': self._gen.new_id(), + } + create = muck.CreateChange(meta, res) + self._store.change(create) + return self._create_response(201, 'create', meta, res) + + def _update_res(self): + rid = self._get_resource_id() + try: + meta, _ = self._get_existing(rid) + except bottle.HTTPError as e: + return e + + rev = self._get_resource_revision() + if meta['rev'] != rev: + return bottle.HTTPError(status=400, body='Wrong revision') + + res = self._get_json_body() + meta['rev'] = self._gen.new_id() + update = muck.UpdateChange(meta, res) + self._store.change(update) + return self._create_response(200, 'change', meta, res) + + def _show_res(self): + rid = self._get_resource_id() + try: + meta, res = self._get_existing(rid) + except bottle.HTTPError as e: + return e + return self._create_response(200, 'show', meta, res) + + def _delete_res(self): + rid = self._get_resource_id() + try: + meta, res = self._get_existing(rid) + except bottle.HTTPError as e: + return e + delete = muck.DeleteChange(meta, res) + self._store.change(delete) + return self._create_response(200, 'delete', meta, res) + + def _search_res(self): + body = self._get_json_body() + cond = body.get('cond') + ms = self._store.get_memory_store() + result = { + 'resources': ms.search(cond), + } + return bottle.HTTPResponse(status=200, body=json.dumps(result)) + + def _get_json_body(self): + f = bottle.request.body + body = f.read() + return json.loads(body, encoding='UTF-8') + + def _get_resource_id(self): + return bottle.request.headers.get('Muck-Id') + + def _get_resource_revision(self): + return bottle.request.headers.get('Muck-Revision') + + def _get_existing(self, rid): + ms = self._store.get_memory_store() + if rid not in ms: + logging.warning('ERROR: {}: not found'.format(rid)) + raise bottle.HTTPResponse(status=404) + + return ms[rid] + + def _create_response(self, status, operation, meta, res): + headers = self._meta_headers(meta) + return bottle.HTTPResponse( + status=status, headers=headers, body=json.dumps(res)) + + def _meta_headers(self, meta): + return { + 'Muck-ID': meta['id'], + 'Muck-Revision': meta['rev'], + } + + +with open(sys.argv[1]) as f: + config = json.load(f) + +logging.basicConfig( + filename=config['log'], level=logging.DEBUG, + format='%(levelname)s %(message)s') + +logging.info('Muck starts') + +signing_key_text = open(config['signing-key-filename']).read() +store = muck.Store(config['store']) + +pid = os.getpid() +with open(config['pid'], 'w') as f: + f.write(str(pid)) + +app = bottle.default_app() +api = MuckAPI(app, store, signing_key_text) +bottle.run(host='127.0.0.1', port=12765) diff --git a/test-key b/test-key new file mode 100644 index 0000000..beeac5d --- /dev/null +++ b/test-key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/test-key.pub b/test-key.pub new file mode 100644 index 0000000..d33c302 --- /dev/null +++ b/test-key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDdr6DQ6injbCNNhbmmjQawqWtnhxRlEtIp+S2wPaucfrBb898C5+atfYd1cI4mVq4ByIhKGYoVlMsAZyUhayMB/vRakmm/RgZwE9dfT/hFAs+ak3Dnlip8i/DYxm4qpO+ZRUj94taApnOaYStRFC4GufvPNrjcGCqxbXf5dyjqscpnhJ9UmMBoANiT5OQEvjHKW8CfxLrQ5Io9bsajCOg9EVmADWjPCWIVDnTt1xDazq4qLF2mfta0H+TjbHLJeJnTZnthMZqd4tA0yAFm1D9hRHvxkHQpRieOz11LF5AX+nXXfvchQeHnZumFFLXnfqrv4gToU+ruFpwK5J3Mn3GGIidhUxOZ6oCs/SQvd4c823Te04bIE7DjxBO03ae255xhIheDsPhZrRoobAMo+P13lkheecAfPKZLgP4r4svLDtZeWX68BYUH4ubYTRaFe+AyniVdDDBU+tuNqzpTsI0YVy23hnd51Rf4CjH8el6ZM4kAIrLdtwbroQUUgjXMbKerIlabVjfaNrDe9WBSZiGw1SF0o71GfHGbUQek1NhsI4ukeKkPx9dA4hCgoxqALkYaoszceZpVQtHtwmEY5iI1y/trOkTALAixKB2x2QDLmFReGFJMnWlWrzGopymLSem3uC4zDFrTM1nh7KWI88aup5snZbNkPWVlZHxgUJzoiw== \ No newline at end of file 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 . + + +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[^}]+)}', 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) -- cgit v1.2.1