aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-10-04 12:46:34 +0100
committereunhhu <[email protected]>2026-10-04 12:46:34 +0100
commit9fd2e16b68e1a83e2605e8a9cbd9b6d04a4da2f6 (patch)
tree2ae9aaf2cc5f3e5d4eef0209d013ac8332c02984 /tests
parent1ff4fd3b1ae2b2ada273d7ffaee8501c9d43d5c0 (diff)
downloadpure-9fd2e16b68e1a83e2605e8a9cbd9b6d04a4da2f6.tar.gz
pure-9fd2e16b68e1a83e2605e8a9cbd9b6d04a4da2f6.zip
Take an app's requests only with a token its tokens file grants (017, 3b)
An app given a tokens file (tokens=FILE in --apps, --tokens FILE for the one app of a host) answers a request only when it bears a token (Authorization: Bearer) whose SHA-256 the file lists with a grant: read for its views, queries and documents, write for all, input=NAME for those inputs alone; 401 unauthorized (WWW-Authenticate: Bearer) without one, 403 forbidden past its grant. A delivery sealed by a key --sender names needs none. The digests are compared in constant time; a connection keeps its last token's digest, so the same token is hashed once. SHA-256 is the host's own (serve/sha256.c), no OpenSSL loaded. A reading puts a changed tokens file in place with the app running on; a file that is not one stops the app. pure-serve --make-token GRANT makes a token and its line. load takes PURE_LOAD_TOKEN.
Diffstat (limited to 'tests')
-rw-r--r--tests/check.py196
1 files changed, 196 insertions, 0 deletions
diff --git a/tests/check.py b/tests/check.py
index b746120..af93b82 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -4246,6 +4246,201 @@ def server_apps_lifecycle(aup, pure, work):
'refused_while_changing': refused[0], 'puts_at_changes': [put['src'] for put, _ in during]}
+TOKENS_SOURCE = """state n = 0
+state got = 0
+input add = message { step: number }
+input other = message { step: number }
+input take = message { k: u32 }
+on add {
+ n = n + add.step
+}
+on other {
+ n = n + 1000
+}
+on take {
+ got = got + take.k
+}
+view total = n
+view taken = got
+"""
+TOKENS_SENDER = """input put = message { k: u32 }
+output out = message { k: u32 }
+on put {
+ send out { k: put.k }
+}
+"""
+
+
+def server_tokens(aup, pure, work):
+ """An app's routes take tokens (docs/design/017, 3b). With a tokens file
+ an app answers no request without a token (401, WWW-Authenticate:
+ Bearer), nor with one it does not list; read reads its views, queries
+ and documents, write does all, input=add posts to add alone (403
+ past a grant), and a refused write changes nothing. Tokens of any
+ length check (SHA-256 against hashlib's), on one connection as well as
+ apart. An app with none is open. A new tokens file on SIGHUP takes
+ effect while the app runs on (no 503); one that is not a tokens file
+ stops the app (closed, not open). A delivery another node sealed with a
+ key this host names comes with no token; an unsealed one needs one.
+ The one app of a host takes --tokens."""
+ import hashlib
+ import http.client
+ import select
+ import signal
+ import struct
+ import threading
+ import time
+ for name, text in (('tokens', TOKENS_SOURCE), ('tokens-sender', TOKENS_SENDER)):
+ (work / f'{name}.aup').write_text(text)
+ compile_module(aup, work / f'{name}.aup', work / f'{name}.pmod')
+ editor = work / 'tokens-editor.pmod'
+ compile_module(aup, EDITOR, editor)
+ serve = pure / 'build/pure-serve'
+ names = {'read': 'r' * 40, 'write': 'w' * 40, 'input=add': 'i' * 40, 'read2': 'q' * 40}
+ lengths = {n: bytes(65 + (k * 7) % 57 for k in range(n)).decode() for n in (1, 55, 56, 63, 64, 65, 119, 200)}
+
+ def digest(token):
+ return hashlib.sha256(token.encode()).hexdigest()
+
+ def tokens_file(grants):
+ return ''.join(f'{grant} {digest(token)} # {grant}\n' for grant, token in grants)
+
+ base = [('read', names['read']), ('write', names['write']), ('input=add', names['input=add'])] + \
+ [('read', token) for token in lengths.values()]
+ (work / 'tokens.list').write_text('# who may call t\n' + tokens_file(base))
+ conf = work / 'tokens.conf'
+ conf.write_text(f"t {work / 'tokens.pmod'} {work / 'tokens-t'} tokens={work / 'tokens.list'}\n"
+ f"open {work / 'tokens.pmod'} {work / 'tokens-open'}\n"
+ f"docs {editor} {work / 'tokens-docs'} tokens={work / 'tokens.list'}\n")
+ host = HttpHost([serve, '--apps', conf])
+
+ def call(method, path, token=None, body=b'', headers=None, port=None):
+ connection = http.client.HTTPConnection('127.0.0.1', port or host.port, timeout=10)
+ try:
+ sent = dict(headers or {})
+ if token is not None:
+ sent['Authorization'] = f'Bearer {token}'
+ if method != 'GET':
+ sent['Content-Length'] = str(len(body))
+ connection.request(method, path, body if method != 'GET' else None, sent)
+ answer = connection.getresponse()
+ return answer.status, answer.getheader('X-Pure-Error'), answer.getheader('WWW-Authenticate'), answer.read()
+ finally:
+ connection.close()
+
+ try:
+ none = call('GET', '/t/views/total')
+ assert none[:3] == (401, 'unauthorized', 'Bearer'), none
+ for method, path in (('POST', '/t/inputs/add?step=1'), ('GET', '/docs/objects/k'), ('PUT', '/t/queries/q'),
+ ('GET', '/t/queries/q')):
+ assert call(method, path)[:2] == (401, 'unauthorized'), (method, path)
+ assert call('GET', '/t/views/total', 'x' * 40)[:2] == (401, 'unauthorized')
+ r, w, i = names['read'], names['write'], names['input=add']
+ assert call('GET', '/t/views/total', r)[0] == 200
+ assert call('GET', '/t/queries/q', r)[:2] == (404, 'no_value') # read, and there is none
+ assert call('POST', '/t/inputs/add?step=1', r)[:2] == (403, 'forbidden')
+ assert call('PUT', '/docs/objects/k', r, b'x', {'If-None-Match': '*'})[:2] == (403, 'forbidden')
+ assert call('POST', '/t/inputs/add?step=1', w)[0] == 200
+ edit = struct.pack('<II', 0, 0) + b'hello' # the edit: start, remove, insert
+ assert call('PUT', '/docs/objects/k', w, edit, {'If-None-Match': '*'})[0] == 200
+ assert call('GET', '/docs/objects/k', r)[::3] == (200, b'hello')
+ assert call('POST', '/t/inputs/add?step=2', i)[0] == 200
+ assert call('POST', '/t/inputs/other?step=1', i)[:2] == (403, 'forbidden')
+ assert call('GET', '/t/views/total', i)[:2] == (403, 'forbidden')
+ assert call('GET', '/t/views/total', w)[3] == b'3' # the refused writes changed nothing
+ for n, token in lengths.items():
+ assert call('GET', '/t/views/total', token)[0] == 200, n
+ assert call('GET', '/open/views/total')[0] == 200 and call('POST', '/open/inputs/add?step=1')[0] == 200
+ # One connection: the same token checked again, then another, then none.
+ connection = http.client.HTTPConnection('127.0.0.1', host.port, timeout=10)
+ seen = []
+ for token in (r, r, r, 'x' * 40, r, None):
+ connection.request('GET', '/t/views/total', headers={'Authorization': f'Bearer {token}'} if token else {})
+ answer = connection.getresponse()
+ answer.read()
+ seen.append(answer.status)
+ connection.close()
+ assert seen == [200, 200, 200, 401, 200, 401], seen
+ # A new tokens file, the app running on: no 503 meanwhile.
+ trouble, done = [], threading.Event()
+
+ def caller():
+ while not done.is_set():
+ status = call('GET', '/t/views/total', w)[0]
+ if status != 200:
+ trouble.append(status)
+
+ thread = threading.Thread(target=caller)
+ thread.start()
+ time.sleep(0.2)
+ (work / 'tokens.list').write_text(tokens_file([('read', names['read2']), ('write', w)]))
+
+ def reading():
+ host.process.send_signal(signal.SIGHUP)
+ ready, _, _ = select.select([host.process.stdout], [], [], 30)
+ assert ready, 'no reading'
+ return {app['name']: app for app in json.loads(host.process.stdout.readline())['apps']}
+
+ did = reading()
+ time.sleep(0.2)
+ done.set()
+ thread.join()
+ assert not trouble and did['t']['did'] == 'changed' and did['docs']['did'] == 'changed' and \
+ did['open']['did'] == 'kept', (trouble[:5], did)
+ assert call('GET', '/t/views/total', r)[:2] == (401, 'unauthorized')
+ assert call('GET', '/t/views/total', names['read2'])[0] == 200
+ # A tokens file that is not one: the app stops (it is never open), and starts once it is one.
+ (work / 'tokens.list').write_text('read not-a-digest\n')
+ did = reading()
+ assert not did['t']['running'] and 'Tokens' in did['t']['why'], did
+ assert call('GET', '/t/views/total')[:2] == (503, 'stopped')
+ (work / 'tokens.list').write_text(tokens_file([('write', w)]))
+ did = reading()
+ assert did['t']['running'] and call('GET', '/t/views/total', w)[3] == b'3', did
+ # An unsealed delivery (this host names no sender) needs a token.
+ delivery = {'X-Pure-Source': 'far.out', 'X-Pure-Sequence': '1', 'Content-Type': 'application/json'}
+ assert call('POST', '/t/inputs/take', None, b'{"k": 5}', delivery)[:2] == (401, 'unauthorized')
+ assert call('POST', '/t/inputs/take', w, b'{"k": 5}', delivery)[0] == 200
+ stats = host.stop()[-1]
+ host = None
+ assert (stats['unauthorized'], stats['forbidden']) == (10, 4), (stats['unauthorized'], stats['forbidden'])
+ # A sealed delivery from a sender the host names comes with no token.
+ keys = {}
+ for side in ('sender', 'receiver'):
+ (work / f'tokens-{side}-key.conf').write_text(f"x {work / 'tokens.pmod'} {work / f'tokens-keys-{side}'}\n")
+ probe = HttpHost([serve, '--apps', work / f'tokens-{side}-key.conf', '--key', work / f'tokens-{side}.key'])
+ keys[side] = probe.info['public_key']
+ probe.stop()
+ receiver = HttpHost([serve, '--apps', conf, '--key', work / 'tokens-receiver.key', '--sender', f"src.out={keys['sender']}"])
+ (work / 'tokens-sender.conf').write_text(
+ f"src {work / 'tokens-sender.pmod'} {work / 'tokens-src'} "
+ f"send=out=http://127.0.0.1:{receiver.port}/t/inputs/take seal=out={keys['receiver']}\n")
+ sender = HttpHost([serve, '--apps', work / 'tokens-sender.conf', '--key', work / 'tokens-sender.key'])
+ try:
+ for k in (1, 2, 3):
+ assert call('POST', f'/src/inputs/put?k={k}', port=sender.port)[0] == 200
+ deadline = time.time() + 20
+ while time.time() < deadline and call('GET', '/t/views/taken', w, port=receiver.port)[3] != b'11':
+ time.sleep(0.05)
+ assert call('GET', '/t/views/taken', w, port=receiver.port)[3] == b'11' # 5, then 1 + 2 + 3
+ finally:
+ sender.stop()
+ receiver.stop()
+ # The one app of a host: --tokens.
+ one = HttpHost([serve, '--module', work / 'tokens.pmod', '--store', work / 'tokens-one', '--tokens', work / 'tokens.list'])
+ try:
+ assert call('GET', '/views/total', port=one.port)[:2] == (401, 'unauthorized')
+ assert call('POST', '/inputs/add?step=4', w, port=one.port)[0] == 200
+ assert call('GET', '/views/total', w, port=one.port)[3] == b'4'
+ finally:
+ one.stop()
+ finally:
+ if host:
+ host.stop()
+ return {'grants': len(base), 'token_lengths': sorted(lengths), 'one_connection': seen,
+ 'unauthorized': stats['unauthorized'], 'forbidden': stats['forbidden']}
+
+
def main():
global EDITOR
parser = argparse.ArgumentParser()
@@ -4298,6 +4493,7 @@ def main():
report['server_apps'] = server_apps(aup, pure, work)
report['server_apps_links'] = server_apps_links(aup, pure, work)
report['server_apps_lifecycle'] = server_apps_lifecycle(aup, pure, work)
+ report['server_tokens'] = server_tokens(aup, pure, work)
print(json.dumps(report))