Files
orbit/scripts/test-console-applications.py

160 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""通过真实 HTTP 验证应用接入,不打印或记录密码、Cookie 与 Token。"""
import copy
import http.cookiejar
import json
import os
from pathlib import Path
import urllib.error
import urllib.request
import uuid
base = os.environ['ORBIT_E2E_URL'].rstrip('/')
user = os.environ['ORBIT_E2E_USER']
password = Path(os.environ['ORBIT_E2E_PASSWORD_FILE']).read_text().strip()
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def call(path, body=None, token=None, headers=None, expected=200, session=True):
hdr = {'Accept': 'application/json', **(headers or {})}
if body is not None:
hdr['Content-Type'] = 'application/json'
if path.startswith('/console/applications'):
hdr.setdefault('X-Orbit-Console', '1')
hdr.setdefault('Origin', base)
if token:
hdr['Authorization'] = 'Bearer ' + token
req = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(), headers=hdr)
try:
response = (opener.open if session else urllib.request.urlopen)(req, timeout=15)
except urllib.error.HTTPError as error:
response = error
with response:
status = response.status
if path.startswith("/console/applications"):
assert response.headers.get("Cache-Control") == "no-store"
value = json.load(response)
assert status == expected, f'{path}: HTTP {status}, expected {expected}; code={value.get("error", {}).get("code")}'
return value.get('data', value)
call('/console/applications', expected=401, session=False)
call('/console/auth/login', {'username': user, 'password': password})
assert call('/console/auth/session')['authenticated']
call('/console/applications/prepare', {}, headers={'Origin': 'https://other.invalid'}, expected=403)
name = 'access-demo-' + uuid.uuid4().hex[:8]
request = {'kind': 'CREATE', 'application': name, 'namespace': 'default', 'environment': 'test', 'queue': 'integration', 'capabilities': ['worker', 'events', 'schedules', 'workflows'], 'valid_days': 30, 'expected_revision': '0', 'reason': 'application onboarding acceptance'}
op = call('/console/applications/prepare', request)
# 固定操作在提交前写入测试私密目录,以便提交结果不明时核对。
private = os.environ.get('ORBIT_E2E_APPLICATION_DIR')
if private:
os.umask(0o077)
Path(private).mkdir(parents=True, exist_ok=True)
Path(private, 'create.operation.json').write_text(json.dumps(op))
first = call('/console/applications/apply', op)
assert not first['deduplicated']
assert call('/console/applications/apply', op)['deduplicated']
changed = copy.deepcopy(op)
changed['input']['reason'] = 'changed'
call('/console/applications/apply', changed, expected=409)
page = call('/console/applications')
assert any(x['namespace_id'] == op['namespace_id'] for x in page['items'])
detail = call('/console/applications/' + op['namespace_id'])
assert op['token'] not in json.dumps(detail)
assert detail['token_vault_enabled'] and detail['credentials'][0]['token_stored']
token_path = '/console/applications/' + op['namespace_id'] + '/credentials/' + op['credential_id'] + '/token/'
assert call(token_path + 'reveal', {'expected_revision':'1'})['token'] == op['token']
call(token_path + 'reveal', {'expected_revision':'1'}, token=op['token'], session=False, expected=401)
call(token_path + 'reveal', {'expected_revision':'1'}, headers={'Origin':'https://other.invalid'}, expected=403)
call(token_path + 'reveal', {'expected_revision':'1'}, headers={'X-Orbit-Console':''}, expected=403)
call(token_path + 'reveal', {'expected_revision':'1'}, headers={'Sec-Fetch-Site':'cross-site'}, expected=403)
call(token_path + 'reveal', {'expected_revision':'2'}, expected=409)
assert call(token_path + 'import', {'expected_revision':'1', 'token':op['token']})['token'] == op['token']
queue = detail['queues'][0]['id']
assert queue == op['queue_id']
call('/console/applications', token=op['token'], expected=401, session=False)
call('/v1/queues/' + queue, token=op['token'], session=False)
sub = call('/v1/subscriptions', {'name': 'result-example', 'queue_id': queue}, token=op['token'], headers={'Idempotency-Key': 'onboarding-subscription'}, expected=201, session=False)
subscription_id = sub['subscription']['id']
task = call('/v1/tasks', {'queue_id': queue, 'handler': 'onboarding.noop', 'handler_version': 'v1', 'payload_version': 'v1', 'payload': {}}, token=op['token'], headers={'Idempotency-Key': 'onboarding-task'}, expected=201, session=False)
task_id = task['task']['id']
call('/v1/tasks/' + task_id + ':cancel', {'expected_revision': task['task']['revision'], 'reason': 'acceptance complete'}, token=op['token'], headers={'Idempotency-Key': 'onboarding-cancel'}, session=False)
expiry = call('/console/applications/prepare', {'kind': 'SET_EXPIRY', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '1', 'valid_days': 0, 'reason': 'long-lived service credential'})
assert 'token' not in expiry and 'expires_at' not in expiry
if private:
Path(private, 'expiry.operation.json').write_text(json.dumps(expiry))
assert call('/console/applications/apply', expiry)['revision'] == '2'
assert call('/console/applications/apply', expiry)['deduplicated']
assert 'expires_at' not in call('/console/applications/' + op['namespace_id'])['credentials'][0]
call('/v1/queues/' + queue, token=op['token'], session=False)
rotate = call('/console/applications/prepare', {'kind': 'ROTATE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '2', 'valid_days': 0, 'grace_days': 7, 'reason': 'rotation acceptance'})
if private:
Path(private, 'rotate.operation.json').write_text(json.dumps(rotate))
assert call('/console/applications/apply', rotate)['revision'] == '3'
assert call('/console/applications/apply', rotate)['deduplicated']
call('/v1/queues/' + queue, token=op['token'], session=False)
call('/v1/queues/' + queue, token=rotate['token'], session=False)
assert call('/console/applications/' + op['namespace_id'])['credentials'][0]['previous_valid_until']
assert call(token_path + 'reveal', {'expected_revision':'3'})['token'] == rotate['token']
call(token_path + 'import', {'expected_revision':'3', 'token':op['token']}, expected=400)
call(token_path + 'reveal', {'expected_revision':'2'}, expected=409)
finalize = call('/console/applications/prepare', {'kind': 'FINALIZE', 'namespace_id': op['namespace_id'], 'credential_id': op['credential_id'], 'expected_revision': '3', 'valid_days': 0, 'reason': 'all services switched'})
if private:
Path(private, 'finalize.operation.json').write_text(json.dumps(finalize))
assert 'token' not in finalize
assert call('/console/applications/apply', finalize)['revision'] == '4'
assert call('/console/applications/apply', finalize)['deduplicated']
assert call(token_path + 'reveal', {'expected_revision':'4'})['token'] == rotate['token']
call('/v1/queues/' + queue, token=op['token'], expected=401, session=False)
call('/v1/queues/' + queue, token=rotate['token'], session=False)
# 重放旧轮换回执不能重新启用已经结束过渡的秘密。
assert call('/console/applications/apply', rotate)['deduplicated']
call('/v1/queues/' + queue, token=op['token'], expected=401, session=False)
current_task = call('/v1/tasks/' + task_id, token=rotate['token'], session=False)
assert current_task['state'] == 'CANCELLED'
current_sub = call('/v1/subscriptions/' + subscription_id, token=rotate['token'], session=False)
assert current_sub['id'] == subscription_id
# 不让联调示例遗留启用的订阅,历史和稳定凭据身份保持不变。
call('/v1/subscriptions/' + subscription_id + ':configure', {'expected_revision': current_sub['revision'], 'enabled': False, 'reason': 'acceptance complete'}, token=rotate['token'], headers={'Idempotency-Key': 'onboarding-pause-subscription'}, session=False)
if private:
Path(private, 'host.env').write_text(f"ORBIT_URL={base}\nORBIT_TOKEN={rotate['token']}\nORBIT_NAMESPACE_ID={op['namespace_id']}\nORBIT_QUEUE_ID={queue}\n")
Path(private, 'result.json').write_text(json.dumps({'application': name, 'namespace_id': op['namespace_id'], 'queue_id': queue, 'credential_id': op['credential_id'], 'task_id': task_id, 'subscription_id': subscription_id}))
# 登录管理员按应用范围读取和管理;SDK 不能使用范围请求头。
scope_headers = {'X-Orbit-Namespace': op['namespace_id'], 'X-Orbit-Console': '1'}
original_scope = call('/console/scope')
scope = call('/console/scope', headers=scope_headers)
assert scope['entry']['application'] == name and scope['entry']['environment'] == 'test'
assert scope['entry']['namespace_id'] == op['namespace_id'] and scope['can_switch']
assert any(q['id'] == queue for q in scope['queues'])
call('/console/scope', token=rotate['token'], headers=scope_headers, expected=403, session=False)
call('/v1/tasks/' + task_id, headers=scope_headers, token=rotate['token'], expected=403)
call('/v1/tasks/' + task_id, expected=404)
assert call('/v1/tasks/' + task_id, headers=scope_headers)['id'] == task_id
assert any(t['id'] == task_id for t in call('/v1/tasks?queue_id=' + queue, headers=scope_headers)['items'])
assert any(s['id'] == subscription_id for s in call('/v1/subscriptions?queue_id=' + queue, headers=scope_headers)['items'])
assert call('/v1/subscriptions/' + subscription_id, headers=scope_headers)['credential_id'] == op['credential_id']
for path in ['/v1/schedules?queue_id=' + queue, '/v1/workflows', '/console/retention/preview', '/console/archive/plans', '/console/archive/jobs']:
call(path, headers=scope_headers)
foreign_queue = original_scope['queues'][0]['id']
assert call('/v1/tasks?queue_id=' + foreign_queue, headers=scope_headers)['items'] == []
call('/v1/queues/' + foreign_queue, headers=scope_headers, expected=404)
call('/console/retention/export', {'kind': 'tasks', 'expected_revision': '0'}, headers={**scope_headers, 'Origin': 'https://other.invalid'}, expected=403)
# 管理订阅不改绑原 SDK 凭据,重复操作仍返回同一回执。
sub_current = call('/v1/subscriptions/' + subscription_id, headers=scope_headers)
pause = {'expected_revision': sub_current['revision'], 'enabled': False, 'reason': 'scoped console acceptance'}
manage_headers = {**scope_headers, 'Idempotency-Key': 'scope-pause-subscription'}
call('/v1/subscriptions/' + subscription_id + ':configure', pause, headers=manage_headers)
call('/v1/subscriptions/' + subscription_id + ':configure', pause, headers=manage_headers)
assert call('/v1/subscriptions/' + subscription_id, token=rotate['token'], session=False)['credential_id'] == op['credential_id']
assert call('/console/scope')['entry']['namespace_id'] == original_scope['entry']['namespace_id']
print('PASS: scoped task/schedule/workflow/subscription/retention/archive reads, subscription management/replay, SDK ownership, CSRF and cross-application isolation')
call('/console/auth/logout', {})
call(token_path + 'reveal', {'expected_revision':'4'}, expected=401)
call('/console/applications', expected=401)
call('/v1/tasks/' + task_id, headers=scope_headers, expected=401)
print('PASS: encrypted Token reveal/import, session/CSRF/no-store/current-generation checks, application create/replay, CSRF/session isolation, permanent expiry, overlapping rotation/finalization, original task/subscription preserved, logout')
print('Test application: ' + name)